From 9a545b1e691bf26cf30fa08888d6cf31ae0ef109 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 30 Sep 2026 11:00:36 +0000 Subject: [PATCH] Add admin-issued accounts with forced first-login password change Why: the user ruled out self-registration (2026-09-30): accounts are created only by admins who set an initial password; first login (and first login after any admin reset) must force a password change. Evidence: - migration v10 auth_accounts (users password/must_change/is_admin + sessions table; upgrade test walks v1<->v10 both directions). - argon2id hash/verify with PHC encoding; store tests cover hash roundtrip, duplicate-owner conflict, must-change lifecycle, session keep/except semantics. - e2e TestAdminAccountFlow: static login page reachable without auth, wrong password 401, login sets HttpOnly cookie + mustChangePassword, admin creates user (409 on duplicate), non-admin create 403, forced change clears flag and old password dies, admin reset revokes sessions and re-arms forced change, API-key channel untouched, 5-fail backoff 429, logout kills session. Full go test ./... green. Impact: middleware now accepts Bearer key OR session cookie and exempts static + /api/auth/login (also fixes the latent case where the login page itself could 401). Embedded no-auth mode unchanged. Refs: proposal 2026-09-30-ui-redesign-and-auth.md section 5 (admin-issued variant, user decision 2026-09-30) --- cmd/agent-system/main.go | 64 +++++++ internal/app/app.go | 64 +++++++ internal/cardsvc/cardsvc_test.go | 2 +- internal/cmdbus/cmdbus_test.go | 2 +- internal/ingest/ingest.go | 2 +- internal/intentgate/gate.go | 6 +- internal/intentgate/gate_test.go | 2 +- internal/pgbus/pgbus.go | 4 +- internal/pgbus/pgbus_test.go | 2 +- internal/plugins/host.go | 24 +-- internal/plugins/host_test.go | 4 +- internal/profiles/profiles.go | 4 +- internal/prompts/prompts_test.go | 2 +- internal/rag/rag_test.go | 2 +- internal/runmgr/events.go | 2 +- internal/runtime/llm.go | 10 +- internal/runtime/model_test.go | 2 +- internal/server/accounts.go | 281 +++++++++++++++++++++++++++++++ internal/server/auth.go | 39 ++++- internal/server/auth_e2e_test.go | 171 +++++++++++++++++++ internal/server/server.go | 19 ++- internal/settings/settings.go | 16 +- internal/store/auth.go | 222 ++++++++++++++++++++++++ internal/store/migrate.go | 31 ++++ internal/store/store.go | 1 + internal/store/store_test.go | 75 ++++++++- internal/tools/http_test.go | 4 +- internal/tools/registry.go | 7 +- 28 files changed, 1007 insertions(+), 57 deletions(-) create mode 100644 internal/server/accounts.go diff --git a/cmd/agent-system/main.go b/cmd/agent-system/main.go index cd253ab..14d9b48 100644 --- a/cmd/agent-system/main.go +++ b/cmd/agent-system/main.go @@ -28,6 +28,8 @@ func main() { os.Exit(runAPIKey(os.Args[2:])) case "config": os.Exit(runConfig(os.Args[2:])) + case "user": + os.Exit(runUser(os.Args[2:])) } } fs := flag.NewFlagSet("serve", flag.ExitOnError) @@ -144,3 +146,65 @@ func runAPIKey(args []string) int { return 2 } } + +// runUser:用户账号管理子命令(管理员发放制,R2)——create / reset-password / list。 +// 密码未显式给定时生成随机值,明文仅此一次输出;创建/重置后首次登录强制改密。 +func runUser(args []string) int { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "用法:agent-system user [--owner x --name y --admin --password p]") + return 2 + } + sub := args[0] + fs := flag.NewFlagSet("user "+sub, flag.ExitOnError) + data := fs.String("data", "./data", "数据目录") + dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档)") + owner := fs.String("owner", "", "用户名(owner)") + name := fs.String("name", "", "显示名") + password := fs.String("password", "", "密码(空 = 随机生成并打印一次)") + admin := fs.Bool("admin", false, "管理员(可管理用户与密钥)") + fs.Parse(args[1:]) + switch sub { + case "create": + if *owner == "" { + fmt.Fprintln(os.Stderr, "user create: --owner 必填") + return 2 + } + pw, err := app.CreateUserAccount(*dsn, *data, *owner, *name, *password, *admin) + if err != nil { + fmt.Fprintln(os.Stderr, "user create:", err) + return 1 + } + fmt.Println(pw) + if *password == "" { + fmt.Fprintln(os.Stderr, "初始密码仅此一次输出;该用户首次登录须修改密码。") + } + 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) + if err != nil { + fmt.Fprintln(os.Stderr, "user reset-password:", err) + return 1 + } + fmt.Println(pw) + if *password == "" { + fmt.Fprintln(os.Stderr, "新密码仅此一次输出;该用户下次登录须修改密码(其余会话已吊销)。") + } + return 0 + case "list": + users, err := app.ListUserAccounts(*dsn, *data) + if err != nil { + fmt.Fprintln(os.Stderr, "user list:", err) + return 1 + } + out, _ := json.MarshalIndent(users, "", " ") + fmt.Println(string(out)) + return 0 + default: + fmt.Fprintln(os.Stderr, "user: 未知子命令", sub) + return 2 + } +} diff --git a/internal/app/app.go b/internal/app/app.go index 597b8a0..f6d811f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -205,6 +205,70 @@ 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 +} + +// ResetUserPassword CLI 重置密码:must_change=1(首登强制改密),吊销全部会话。 +func ResetUserPassword(storageDSN, dataDir, owner, password string) (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 { + if err := st.SetUserPassword(owner, hash); err != nil { + return err + } + st.DeleteUserSessions(owner, "") + return nil + }); err != nil { + return "", err + } + return password, nil +} + +// ListUserAccounts CLI 用户列表。 +func ListUserAccounts(storageDSN, dataDir string) ([]store.AdminUser, error) { + var out []store.AdminUser + err := withStore(storageDSN, dataDir, func(st *store.Store) error { + var e error + out, e = st.ListUsersAdmin() + return e + }) + return out, err +} + +func withStore(storageDSN, dataDir string, fn func(*store.Store) error) error { + var st *store.Store + var err error + if storageDSN != "" { + st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts")) + } else { + st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db")) + } + if err != nil { + return err + } + defer st.Close() + return fn(st) +} + // CreateAPIKey 是 apikey 子命令的实现:在指定存储档上为 owner 创建密钥(明文仅打印一次)。 func CreateAPIKey(storageDSN, dataDir, owner, name string) (string, error) { var st *store.Store diff --git a/internal/cardsvc/cardsvc_test.go b/internal/cardsvc/cardsvc_test.go index 99dd415..de5507b 100644 --- a/internal/cardsvc/cardsvc_test.go +++ b/internal/cardsvc/cardsvc_test.go @@ -5,8 +5,8 @@ import ( "sync" "testing" - core "agent-system-core" "agent-system-backend/internal/store" + core "agent-system-core" ) func openTest(t *testing.T) (*store.Store, *Service, func() []core.StreamEvent) { diff --git a/internal/cmdbus/cmdbus_test.go b/internal/cmdbus/cmdbus_test.go index 8feb8cf..0a32a56 100644 --- a/internal/cmdbus/cmdbus_test.go +++ b/internal/cmdbus/cmdbus_test.go @@ -47,7 +47,7 @@ func newTestBus(a AuditStore, c CardPort) *Bus { func(ctx context.Context, req Request) (any, error) { return map[string]any{"pong": true}, nil }) _ = b.RegisterBuiltin(agentcommand.CommandSpec{ ID: "demo.risky", Title: "危险演示", Danger: agentcommand.DangerHigh, - Description: "高危命令演示", + Description: "高危命令演示", ParamsSchema: json.RawMessage(`{"type":"object","required":["target"],"properties":{"target":{"type":"string"}}}`), }, "/risky", func(ctx context.Context, req Request) (any, error) { t, _ := req.Params["target"].(string) diff --git a/internal/ingest/ingest.go b/internal/ingest/ingest.go index cb1d74a..fe254c3 100644 --- a/internal/ingest/ingest.go +++ b/internal/ingest/ingest.go @@ -13,8 +13,8 @@ import ( "strings" "time" - pdflib "github.com/ledongthuc/pdf" core "agent-system-core" + pdflib "github.com/ledongthuc/pdf" ) // Parse 按扩展名分派内置解析器;无法解析时返回带 warning 的空文档(显式失败,不静默)。 diff --git a/internal/intentgate/gate.go b/internal/intentgate/gate.go index 518b6ab..a54dec2 100644 --- a/internal/intentgate/gate.go +++ b/internal/intentgate/gate.go @@ -13,9 +13,9 @@ import ( ) type Gate struct { - mu sync.RWMutex - reg *agentintent.Registry - rec *agentintent.FunnelRecognizer + mu sync.RWMutex + reg *agentintent.Registry + rec *agentintent.FunnelRecognizer specs int } diff --git a/internal/intentgate/gate_test.go b/internal/intentgate/gate_test.go index df85178..0c0c917 100644 --- a/internal/intentgate/gate_test.go +++ b/internal/intentgate/gate_test.go @@ -20,7 +20,7 @@ func demoSpec(t *testing.T) json.RawMessage { return raw(t, map[string]any{ "id": "demo.show", "domain": "demo", "version": 1, "description": "示例意图", - "matcher": map[string]any{"includes_any": []string{"示例", "演示"}}, + "matcher": map[string]any{"includes_any": []string{"示例", "演示"}}, }) } diff --git a/internal/pgbus/pgbus.go b/internal/pgbus/pgbus.go index 171be69..32792ec 100644 --- a/internal/pgbus/pgbus.go +++ b/internal/pgbus/pgbus.go @@ -2,7 +2,7 @@ // 落盘事务提交后 NOTIFY agent_events '|';每实例一个 LISTEN 连接, // 收到通知按 seq 从库取回事件并转发本地 bus(SSE 以 seq 去重,双投无害)。 // 多实例部署契约:run 在其归属实例内存中执行,会话流量需按会话亲和路由 -//(网关/客户端择点);本包只解决“其他实例的订阅者”可见性。 +// (网关/客户端择点);本包只解决“其他实例的订阅者”可见性。 package pgbus import ( @@ -12,9 +12,9 @@ import ( "strings" "time" - core "agent-system-core" "agent-system-backend/internal/bus" "agent-system-backend/internal/store" + core "agent-system-core" "github.com/jackc/pgx/v5" ) diff --git a/internal/pgbus/pgbus_test.go b/internal/pgbus/pgbus_test.go index 4fe778f..9956d03 100644 --- a/internal/pgbus/pgbus_test.go +++ b/internal/pgbus/pgbus_test.go @@ -13,9 +13,9 @@ import ( "testing" "time" - core "agent-system-core" "agent-system-backend/internal/bus" "agent-system-backend/internal/store" + core "agent-system-core" ) func pgTestStore(t *testing.T) (*store.Store, string) { diff --git a/internal/plugins/host.go b/internal/plugins/host.go index d52dab1..e8c605e 100644 --- a/internal/plugins/host.go +++ b/internal/plugins/host.go @@ -12,25 +12,25 @@ import ( "strings" "sync" - core "agent-system-core" "agent-system-backend/internal/settings" "agent-system-backend/internal/store" "agent-system-backend/internal/tools" + core "agent-system-core" ) // Loaded 是一个已装载插件的内存态(注册结果快照)。 type Loaded struct { - Manifest core.PluginManifest - Dir string - Panels []core.PluginPanel - Cards map[string][]core.UINode // kind → 模板节点 - Workflows []core.WorkflowDef - Intents []json.RawMessage // agent-intent overlay 形态(目录侧消费) - Commands []json.RawMessage // Agent-Command spec overlay(cmdbus 注册) - Workspaces []core.PluginWorkspace // 工作区弹层声明(OverlayHost 渲染) - Prompts map[string]string // 文件名 → 内容(画像模板) + Manifest core.PluginManifest + Dir string + Panels []core.PluginPanel + Cards map[string][]core.UINode // kind → 模板节点 + Workflows []core.WorkflowDef + Intents []json.RawMessage // agent-intent overlay 形态(目录侧消费) + Commands []json.RawMessage // Agent-Command spec overlay(cmdbus 注册) + Workspaces []core.PluginWorkspace // 工作区弹层声明(OverlayHost 渲染) + Prompts map[string]string // 文件名 → 内容(画像模板) PluginSettings []core.SettingDesc - Defaults map[string]any // 插件设置默认值(并入有效设置) + Defaults map[string]any // 插件设置默认值(并入有效设置) } // Host 管理插件目录装载与注册。 @@ -150,7 +150,7 @@ func (h *Host) loadOne(dir string) error { // cards/*.json:{kind, data:[UINode]} for _, name := range m.Provides.Cards { var card struct { - Kind string `json:"kind"` + Kind string `json:"kind"` Data []core.UINode `json:"data"` } if err := readAsset(dir, "cards", name, &card); err != nil { diff --git a/internal/plugins/host_test.go b/internal/plugins/host_test.go index 1088e57..9af296e 100644 --- a/internal/plugins/host_test.go +++ b/internal/plugins/host_test.go @@ -5,10 +5,10 @@ import ( "path/filepath" "testing" - core "agent-system-core" "agent-system-backend/internal/settings" "agent-system-backend/internal/store" "agent-system-backend/internal/tools" + core "agent-system-core" ) func newHost(t *testing.T) (*Host, *tools.Registry, string) { @@ -34,7 +34,7 @@ func writeExamplePlugin(t *testing.T, dir, id string) { for _, sub := range []string{"tools", "panels", "cards", "intents", "prompts"} { os.MkdirAll(filepath.Join(root, sub), 0o755) } - writeFile := func(rel, content string) { + writeFile := func(rel, content string) { if err := os.WriteFile(filepath.Join(root, rel), []byte(content), 0o644); err != nil { t.Fatal(err) } diff --git a/internal/profiles/profiles.go b/internal/profiles/profiles.go index 669fef4..99275b2 100644 --- a/internal/profiles/profiles.go +++ b/internal/profiles/profiles.go @@ -4,11 +4,11 @@ package profiles import ( "reflect" - core "agent-system-core" "agent-system-backend/internal/store" + core "agent-system-core" ) -// OwnerDefault 与 store 保持一致的默认租户(内置画像共享,owner='')。 +// OwnerDefault 与 store 保持一致的默认租户(内置画像共享,owner=”)。 const OwnerDefault = store.OwnerDefault const DefaultProfileID = "default" diff --git a/internal/prompts/prompts_test.go b/internal/prompts/prompts_test.go index 83b158f..d787aa2 100644 --- a/internal/prompts/prompts_test.go +++ b/internal/prompts/prompts_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" - core "agent-system-core" "agent-system-backend/internal/store" + core "agent-system-core" ) // 内置库必须全部可解析、ID 唯一、层级合法、触发引用完整。 diff --git a/internal/rag/rag_test.go b/internal/rag/rag_test.go index b5458fe..9f38de7 100644 --- a/internal/rag/rag_test.go +++ b/internal/rag/rag_test.go @@ -7,9 +7,9 @@ import ( "strings" "testing" - core "agent-system-core" "agent-system-backend/internal/settings" "agent-system-backend/internal/store" + core "agent-system-core" ) // fakeEmbedder 确定性向量:按首字符映射,验证混合检索排序无需真实模型。 diff --git a/internal/runmgr/events.go b/internal/runmgr/events.go index e1cedcc..0ba9fb8 100644 --- a/internal/runmgr/events.go +++ b/internal/runmgr/events.go @@ -6,9 +6,9 @@ import ( "sync" "time" - core "agent-system-core" "agent-system-backend/internal/bus" "agent-system-backend/internal/store" + core "agent-system-core" ) // eventBuffer 是会话事件批处理器(写放大治理): diff --git a/internal/runtime/llm.go b/internal/runtime/llm.go index 215e69f..6fa959b 100644 --- a/internal/runtime/llm.go +++ b/internal/runtime/llm.go @@ -7,10 +7,10 @@ import ( "time" agentllm "agent-native/runtime/llm" - core "agent-system-core" "agent-system-backend/internal/ctxasm" "agent-system-backend/internal/prompts" "agent-system-backend/internal/squadadapter" + core "agent-system-core" ) // LLMRunner 主对话执行器:复用 agent-native 的 OpenAI 兼容流式客户端(不自研接入层)。 @@ -24,10 +24,10 @@ type LLMRunner struct { func (l *LLMRunner) Name() string { return "llm" } const ( - toolDelegate = "delegate_task" - toolPlan = "update_plan" - toolTodos = "update_todos" - toolSquad = "squad_orchestrate" + toolDelegate = "delegate_task" + toolPlan = "update_plan" + toolTodos = "update_todos" + toolSquad = "squad_orchestrate" ) // resolveSystem 经提示词库组装 system 段(基座+环境+命中技能),并产出可观测条目。 diff --git a/internal/runtime/model_test.go b/internal/runtime/model_test.go index 8a86d51..97d6a12 100644 --- a/internal/runtime/model_test.go +++ b/internal/runtime/model_test.go @@ -31,7 +31,7 @@ func TestResolveChatModelLayers(t *testing.T) { func TestResolveLightweightFallback(t *testing.T) { eff := effOf(map[string]any{"models": map[string]any{ - "chat": map[string]any{"model": "main-m", "baseUrl": "https://main", "apiKey": "k"}, + "chat": map[string]any{"model": "main-m", "baseUrl": "https://main", "apiKey": "k"}, "lightweight": map[string]any{"model": ""}, }}) got := ResolveLightweightModel(core.AgentProfile{}, eff) diff --git a/internal/server/accounts.go b/internal/server/accounts.go new file mode 100644 index 0000000..92688db --- /dev/null +++ b/internal/server/accounts.go @@ -0,0 +1,281 @@ +package server + +// 账号与会话 API(阶段 2:管理员发放制,2026-09-30 用户裁定): +// 无自助注册;登录(用户名+密码)建立服务端会话(HttpOnly cookie); +// 初始/重置密码后首登强制改密;管理员建号与重置密码。 + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "strings" + "sync" + "time" + + "agent-system-backend/internal/store" +) + +var errBadPassword = errors.New("password must be 8-256 characters") + +func decodeJSON(r *http.Request, v any) error { + return json.NewDecoder(r.Body).Decode(v) +} + +// 登录失败退避(进程内;管理员发放制下用户量小,进程内即可,实例重启清零)。 +var loginGuard = struct { + sync.Mutex + fails map[string][]time.Time +}{fails: map[string][]time.Time{}} + +const ( + loginMaxFails = 5 + loginLockWindow = 30 * time.Second +) + +func loginLocked(owner string) bool { + loginGuard.Lock() + defer loginGuard.Unlock() + cut := time.Now().Add(-loginLockWindow) + kept := loginGuard.fails[owner][:0] + for _, t := range loginGuard.fails[owner] { + if t.After(cut) { + kept = append(kept, t) + } + } + loginGuard.fails[owner] = kept + return len(kept) >= loginMaxFails +} + +func loginFail(owner string) { + loginGuard.Lock() + defer loginGuard.Unlock() + loginGuard.fails[owner] = append(loginGuard.fails[owner], time.Now()) +} + +func loginClear(owner string) { + loginGuard.Lock() + defer loginGuard.Unlock() + delete(loginGuard.fails, owner) +} + +func setSessionCookie(w http.ResponseWriter, token string, maxAge int) { + http.SetCookie(w, &http.Cookie{ + Name: SessionCookie, Value: token, Path: "/", HttpOnly: true, + SameSite: http.SameSiteLaxMode, MaxAge: maxAge, + }) +} + +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, + } +} + +// login:用户名+密码 → 会话。失败统一 401(不区分账号是否存在); +// 连续失败触发进程内退避(429)。 +func (s *Server) login(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 { + Owner string `json:"owner"` + Password string `json:"password"` + } + if err := decodeJSON(r, &req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + owner := strings.TrimSpace(req.Owner) + if owner == "" || len(req.Password) > 256 { + http.Error(w, "owner and password required", http.StatusBadRequest) + return + } + if loginLocked(owner) { + http.Error(w, "too many failed attempts; retry later", http.StatusTooManyRequests) + return + } + st, ok := s.st.VerifyLogin(owner, req.Password) + if !ok { + loginFail(owner) + http.Error(w, "invalid owner or password", http.StatusUnauthorized) + return + } + loginClear(owner) + token, err := s.st.CreateSession(owner) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + setSessionCookie(w, token, 7*24*3600) + writeJSON(w, http.StatusOK, authStateJSON(st)) +} + +// logout:注销当前会话。 +func (s *Server) logout(w http.ResponseWriter, r *http.Request) { + if tok := sessionToken(r); tok != "" { + s.st.DeleteSession(tok) + } + setSessionCookie(w, "", -1) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// changePassword:会话登录者改密。校验当前密码 → 新哈希落库 → 清 must_change → +// 吊销其余会话(保留当前)。重置后的首登改密走同一路径。 +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) + if !ok { + http.Error(w, "session expired", http.StatusUnauthorized) + return + } + var req struct { + Current string `json:"current"` + Next string `json:"next"` + } + if err := decodeJSON(r, &req); err != nil { + 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 + } + hash, err := store.HashPassword(req.Next) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + 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 + } + s.st.DeleteUserSessions(owner, tok) + st, _ := s.st.GetUserAuthState(owner) + writeJSON(w, http.StatusOK, authStateJSON(st)) +} + +func validPassword(pw string) error { + if len(pw) < 8 { + return errBadPassword + } + if len(pw) > 256 { + return errBadPassword + } + return nil +} + +// ---- 管理员:用户发放与重置(requireAdmin)---- + +func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) (string, bool) { + owner := s.owner(r) + st, err := s.st.GetUserAuthState(owner) + if err != nil || !st.IsAdmin { + http.Error(w, "admin required", http.StatusForbidden) + return "", false + } + return owner, true +} + +func (s *Server) listAdminUsers(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireAdmin(w, r); !ok { + return + } + users, err := s.st.ListUsersAdmin() + writeJSONOr(w, users, err) +} + +func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireAdmin(w, r); !ok { + return + } + var req struct { + Owner string `json:"owner"` + Name string `json:"name"` + Password string `json:"password"` + IsAdmin bool `json:"isAdmin"` + } + if err := decodeJSON(r, &req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + req.Owner = strings.TrimSpace(req.Owner) + if req.Owner == "" || strings.ContainsAny(req.Owner, " \t/") { + http.Error(w, "owner required (no spaces or slashes)", http.StatusBadRequest) + return + } + if err := validPassword(req.Password); err != nil { + http.Error(w, "initial 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.CreateAccount(req.Owner, strings.TrimSpace(req.Name), hash, req.IsAdmin); err != nil { + writeStoreErr(w, err) + return + } + st, _ := s.st.GetUserAuthState(req.Owner) + writeJSON(w, http.StatusOK, map[string]any{ + "owner": st.Owner, "name": st.Name, "isAdmin": st.IsAdmin, + "mustChangePassword": st.MustChangePassword, + }) +} + +// resetAdminUserPassword:管理员重置密码 → must_change=1,吊销该用户全部会话。 +func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireAdmin(w, r); !ok { + return + } + 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.SetUserPassword(owner, hash); err != nil { + writeStoreErr(w, err) + return + } + s.st.DeleteUserSessions(owner, "") + st, _ := s.st.GetUserAuthState(owner) + writeJSON(w, http.StatusOK, map[string]any{ + "owner": st.Owner, "mustChangePassword": st.MustChangePassword, + }) +} + +// RandomPassword 生成随机初始密码(管理员/CLI 未显式给定密码时)。 +func RandomPassword() string { + raw := make([]byte, 9) + rand.Read(raw) + return hex.EncodeToString(raw) +} diff --git a/internal/server/auth.go b/internal/server/auth.go index c6dd162..a189b0f 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -23,29 +23,54 @@ func NewAuthenticator(st *store.Store, enabled bool) *Authenticator { return &Authenticator{st: st, Enabled: enabled} } +// SessionCookie 会话 cookie 名(HttpOnly;SSE 的 EventSource 同源自动携带)。 +const SessionCookie = "as_session" + func (a *Authenticator) Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if a == nil || !a.Enabled { next.ServeHTTP(w, r.WithContext(withOwner(r, store.OwnerDefault))) return } + // 豁免:静态前端(登录页须可加载)与登录入口本身。 + p := r.URL.Path + if !strings.HasPrefix(p, "/api/") || p == "/api/auth/login" { + next.ServeHTTP(w, r) + return + } + // 凭证二选一:Authorization: Bearer (程序化;?key= 兼容 SSE 老客户端) + // 或会话 cookie(浏览器登录;2026-09-30 R2 管理员发放制)。 key := bearerKey(r) if key == "" { key = r.URL.Query().Get("key") } - if key == "" { - http.Error(w, "unauthorized: missing API key (Authorization: Bearer )", http.StatusUnauthorized) + if key != "" { + owner, ok := a.st.AuthOwner(key) + if !ok { + http.Error(w, "unauthorized: invalid or revoked API key", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r.WithContext(withOwner(r, owner))) return } - owner, ok := a.st.AuthOwner(key) - if !ok { - http.Error(w, "unauthorized: invalid or revoked API key", http.StatusUnauthorized) - return + if c, err := r.Cookie(SessionCookie); err == nil { + if owner, ok := a.st.SessionOwner(c.Value); ok { + next.ServeHTTP(w, r.WithContext(withOwner(r, owner))) + return + } } - next.ServeHTTP(w, r.WithContext(withOwner(r, owner))) + http.Error(w, "unauthorized: login required", http.StatusUnauthorized) }) } +// sessionToken 从请求取会话明文(无会话返回空)。 +func sessionToken(r *http.Request) string { + if c, err := r.Cookie(SessionCookie); err == nil { + return c.Value + } + return "" +} + func withOwner(r *http.Request, owner string) context.Context { return context.WithValue(r.Context(), ownerCtxKey{}, owner) } diff --git a/internal/server/auth_e2e_test.go b/internal/server/auth_e2e_test.go index dab1b8c..ed7c477 100644 --- a/internal/server/auth_e2e_test.go +++ b/internal/server/auth_e2e_test.go @@ -6,6 +6,7 @@ package server import ( "bytes" "encoding/json" + "io" "net/http" "net/http/httptest" "os" @@ -258,3 +259,173 @@ func TestBackupEndpoint(t *testing.T) { t.Fatalf("backup file: %v", err) } } + +// TestAdminAccountFlow 管理员发放制全流程(R2):CLI 语义的建号/首登改密/重置再改密/越权负例。 +func TestAdminAccountFlow(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + + // 引导:CLI 语义建管理员(随机初始密码,must_change=1)。 + bootHash, err := store.HashPassword("boot-pass-00") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("admin", "管理员", bootHash, true); err != nil { + t.Fatal(err) + } + + // 登录前:静态页可访问、/api 需登录。 + req := httptest.NewRequest("GET", "/", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code == http.StatusUnauthorized { + t.Fatal("static login page must load without auth") + } + + // 错误密码 401;正确密码建会话(Set-Cookie)。 + req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"admin","password":"wrong"}`)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("wrong password: %d", rec.Code) + } + req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"admin","password":"boot-pass-00"}`)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("login: %d %s", rec.Code, rec.Body.String()) + } + cookie := rec.Header().Get("Set-Cookie") + if !strings.Contains(cookie, SessionCookie+"=") || !strings.Contains(cookie, "HttpOnly") { + t.Fatalf("session cookie missing: %q", cookie) + } + tok := strings.Split(strings.Split(cookie, SessionCookie+"=")[1], ";")[0] + if !strings.Contains(rec.Body.String(), `"mustChangePassword":true`) { + t.Fatalf("login must report mustChangePassword: %s", rec.Body.String()) + } + + session := func(method, path, body string) *httptest.ResponseRecorder { + var rdr io.Reader + if body != "" { + rdr = strings.NewReader(body) + } + req := httptest.NewRequest(method, path, rdr) + req.AddCookie(&http.Cookie{Name: SessionCookie, Value: tok}) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec + } + + // 会话可访问 API;/api/me 携带身份与管理员位。 + 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.StatusOK { + t.Fatalf("admin create user: %d %s", rec.Code, rec.Body.String()) + } + // 重名冲突 409。 + if rec := session("POST", "/api/admin/users", `{"owner":"alice","password":"alice-init-01"}`); rec.Code != http.StatusConflict { + t.Fatalf("duplicate owner must 409: %d", rec.Code) + } + + // alice 首登:mustChangePassword=true;改密后清除。 + 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() + h.ServeHTTP(rec, req) + aliceTok := strings.Split(strings.Split(rec.Header().Get("Set-Cookie"), SessionCookie+"=")[1], ";")[0] + alice := func(method, path, body string) *httptest.ResponseRecorder { + var rdr io.Reader + if body != "" { + rdr = strings.NewReader(body) + } + req := httptest.NewRequest(method, path, rdr) + req.AddCookie(&http.Cookie{Name: SessionCookie, Value: aliceTok}) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec + } + 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) + } + // 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) + } + 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()) + } + // 旧密码不能再登录;新密码可以且无需改密。 + 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() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("old password must fail after change: %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()) + } + if rec := alice("GET", "/api/me", ""); rec.Code != http.StatusUnauthorized { + t.Fatalf("alice old session must be revoked after reset: %d", rec.Code) + } + req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"reset-init-02"}`)) + 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()) + } + + // API key 通道兼容:密钥登录照旧(并具备其 owner 身份)。 + key, err := st.CreateAPIKey("admin", "cli") + if err != nil { + t.Fatal(err) + } + req = httptest.NewRequest("GET", "/api/me", nil) + req.Header.Set("Authorization", "Bearer "+key) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("api key still works: %d", rec.Code) + } + + // 登录失败退避:连续 5 次错误后 429。 + for i := 0; i < 5; i++ { + req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"x"}`)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + } + req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"reset-init-02"}`)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("backoff expected 429: %d", rec.Code) + } + + // 登出:会话失效。 + if rec := session("POST", "/api/auth/logout", ""); rec.Code != http.StatusOK { + t.Fatalf("logout: %d", rec.Code) + } + if rec := session("GET", "/api/me", ""); rec.Code != http.StatusUnauthorized { + t.Fatalf("session must die after logout: %d", rec.Code) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index b04f619..32e42c9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -127,6 +127,14 @@ func (s *Server) WireBuiltinCommands() { func (s *Server) Handler() http.Handler { mux := http.NewServeMux() + // ---- 认证与会话(管理员发放制,R2)---- + mux.HandleFunc("POST /api/auth/login", s.login) + 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("POST /api/admin/users", s.createAdminUser) + mux.HandleFunc("POST /api/admin/users/{owner}/password", s.resetAdminUserPassword) + // ---- 项目 / 会话 ---- mux.HandleFunc("GET /api/projects", s.listProjects) mux.HandleFunc("POST /api/projects", s.createProject) @@ -392,7 +400,12 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) { if u, err := s.st.GetUser(owner); err == nil && u.Name != "" { name = u.Name } - writeJSON(w, http.StatusOK, map[string]any{"owner": owner, "name": name, "auth": s.Auth != nil && s.Auth.Enabled}) + out := map[string]any{"owner": owner, "name": name, "auth": s.Auth != nil && s.Auth.Enabled} + if st, err := s.st.GetUserAuthState(owner); err == nil { + out["isAdmin"] = st.IsAdmin + out["mustChangePassword"] = st.MustChangePassword + } + writeJSON(w, http.StatusOK, out) } func (s *Server) createConversation(w http.ResponseWriter, r *http.Request) { @@ -1178,6 +1191,10 @@ func writeStoreErr(w http.ResponseWriter, err error) { http.Error(w, err.Error(), http.StatusNotFound) return } + if errors.Is(err, store.ErrConflict) { + http.Error(w, err.Error(), http.StatusConflict) + return + } if errors.Is(err, runmgr.ErrConversationBusy) { http.Error(w, err.Error(), http.StatusConflict) return diff --git a/internal/settings/settings.go b/internal/settings/settings.go index c5d0d79..852eb15 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -3,13 +3,13 @@ package settings import ( - core "agent-system-core" "agent-system-backend/internal/store" + core "agent-system-core" ) // Scope 层级键:user 与 project: 落库;conversation 经会话画像选择实现。 const ( - ScopeUser = "user" + ScopeUser = "user" ScopeProjectPrefix = "project:" ) @@ -30,10 +30,10 @@ func SystemDefaults() map[string]any { "tavily": map[string]any{"apiKeys": "", "endpoint": ""}, // 网络搜索(逗号分隔密钥池;空 = 不注册搜索工具) }, "runtime": map[string]any{ - "mock": false, // true = 全部会话走 mock runner(演示/测试) - "squad": true, // 小队编排(Agent-Squad 引擎)可用 - "rateLimitPerMin": 0, // 租户限流(次/分;0 = 不限) - "redisAddr": "", // 限流共享后端(原生 Redis addr;空 = 进程内) + "mock": false, // true = 全部会话走 mock runner(演示/测试) + "squad": true, // 小队编排(Agent-Squad 引擎)可用 + "rateLimitPerMin": 0, // 租户限流(次/分;0 = 不限) + "redisAddr": "", // 限流共享后端(原生 Redis addr;空 = 进程内) }, "storage": map[string]any{ "dataDir": "./data", // 启动注入实际值(--data) @@ -43,8 +43,8 @@ func SystemDefaults() map[string]any { "maxToolResultChars": 20000, }, "retrieval": map[string]any{"topK": 5}, - "ui": map[string]any{"theme": "dark"}, - "plugins": map[string]any{"dir": "./plugins"}, + "ui": map[string]any{"theme": "dark"}, + "plugins": map[string]any{"dir": "./plugins"}, } } diff --git a/internal/store/auth.go b/internal/store/auth.go index ff8df12..84b1684 100644 --- a/internal/store/auth.go +++ b/internal/store/auth.go @@ -3,10 +3,15 @@ package store import ( "crypto/rand" "crypto/sha256" + "crypto/subtle" "database/sql" + "encoding/base64" "encoding/hex" "errors" + "strings" "time" + + "golang.org/x/crypto/argon2" ) // ---- 认证与租户(阶段 1:API key 认证,user 即租户 owner)---- @@ -144,3 +149,220 @@ func (s *Store) AuthOwner(plainKey string) (string, bool) { s.db.Exec(s.q(`UPDATE api_keys SET last_used_at=? WHERE key_hash=?`), nowISO(), hashKey(plainKey)) return owner, true } + +// ---- 账号密码与会话(阶段 2:管理员发放制,2026-09-30 裁定)---- +// 无自助注册:账号由管理员(或 CLI 引导)创建;初始/重置密码后 must_change_password=1, +// 首次登录强制改密。会话为服务端状态:库存 SHA-256 摘要,cookie 只发一次明文。 + +const sessionTTL = 7 * 24 * time.Hour + +// AdminUser 管理员用户列表的展示视图(不含哈希)。 +type AdminUser struct { + Owner string `json:"owner"` + Name string `json:"name"` + IsAdmin bool `json:"isAdmin"` + MustChangePassword bool `json:"mustChangePassword"` + HasPassword bool `json:"hasPassword"` + PasswordSetAt *time.Time `json:"passwordSetAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +// HashPassword 生成 argon2id PHC 串(m=64MiB,t=2,p=2;随机盐)。 +func HashPassword(password string) (string, error) { + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return "", err + } + key := argon2.IDKey([]byte(password), salt, 2, 64*1024, 2, 32) + return "$argon2id$v=19$m=65536,t=2,p=2$" + + base64.RawStdEncoding.EncodeToString(salt) + "$" + + base64.RawStdEncoding.EncodeToString(key), nil +} + +// VerifyPassword 校验 PHC 串(常量时间比较;坏格式按不匹配处理)。 +func VerifyPassword(hash, password string) bool { + parts := strings.Split(hash, "$") + if len(parts) != 6 || parts[1] != "argon2id" { + return false + } + var m uint32 = 65536 + var t uint32 = 2 + var p uint8 = 2 + for _, kv := range strings.Split(parts[3], ",") { + kv2 := strings.SplitN(kv, "=", 2) + if len(kv2) != 2 { + continue + } + var v int + for _, c := range kv2[1] { + v = v*10 + int(c-'0') + } + switch kv2[0] { + case "m": + m = uint32(v) + case "t": + t = uint32(v) + case "p": + p = uint8(v) + } + } + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false + } + want, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return false + } + got := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(want))) + return subtle.ConstantTimeCompare(got, want) == 1 +} + +// CreateAccount 管理员建号:owner 唯一;初始密码 must_change=1。 +func (s *Store) CreateAccount(owner, name, passwordHash string, isAdmin bool) error { + if _, err := s.GetUser(owner); err == nil { + return ErrConflict + } + if err := s.EnsureUser(owner, name); err != nil { + return err + } + _, err := s.db.Exec(s.q(`UPDATE users SET name=?, password_hash=?, password_set_at=?, must_change_password=1, is_admin=? WHERE owner=?`), + name, passwordHash, nowISO(), boolInt(isAdmin), owner) + return err +} + +// 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=1 WHERE owner=?`), + passwordHash, nowISO(), owner) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +// ClearMustChangePassword 改密完成后清标志(改密者会话保留,其余吊销由调用方处理)。 +func (s *Store) ClearMustChangePassword(owner string) error { + _, err := s.db.Exec(s.q(`UPDATE users SET must_change_password=0 WHERE owner=?`), owner) + return err +} + +// UserAuthState 登录与身份视图。 +type UserAuthState struct { + Owner string + Name string + IsAdmin bool + MustChangePassword bool + HasPassword bool +} + +// GetUserAuthState 按 owner 取认证态(含会话/APIOwner 路径共用)。 +func (s *Store) GetUserAuthState(owner string) (UserAuthState, error) { + var st UserAuthState + var hash, ca string + var mustChange, isAdmin int + err := s.db.QueryRow(s.q(`SELECT owner,COALESCE(name,''),password_hash,must_change_password,is_admin,created_at FROM users WHERE owner=?`), owner). + Scan(&st.Owner, &st.Name, &hash, &mustChange, &isAdmin, &ca) + if errors.Is(err, sql.ErrNoRows) { + return st, ErrNotFound + } + if err != nil { + return st, err + } + st.MustChangePassword = mustChange == 1 + st.IsAdmin = isAdmin == 1 + st.HasPassword = hash != "" + return st, nil +} + +// VerifyLogin 校验用户名密码。 +func (s *Store) VerifyLogin(owner, password string) (UserAuthState, bool) { + var hash string + err := s.db.QueryRow(s.q(`SELECT password_hash FROM users WHERE owner=?`), owner).Scan(&hash) + if err != nil || hash == "" || !VerifyPassword(hash, password) { + return UserAuthState{}, false + } + st, err := s.GetUserAuthState(owner) + if err != nil { + return UserAuthState{}, false + } + return st, true +} + +// ---- 会话 ---- + +// CreateSession 建会话:明文 token 仅此一次返回;库存 SHA-256 摘要与到期时间。 +func (s *Store) CreateSession(owner string) (string, error) { + 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()) + return token, err +} + +// SessionOwner 校验会话:命中且未过期返回 owner(尽力刷新 last_seen)。 +func (s *Store) SessionOwner(token string) (string, bool) { + if token == "" { + 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) + if err != nil { + 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 + } + s.db.Exec(s.q(`UPDATE sessions SET last_seen_at=? WHERE token_hash=?`), nowISO(), hashKey(token)) + return owner, true +} + +// DeleteSession 注销当前会话。 +func (s *Store) DeleteSession(token string) { + s.db.Exec(s.q(`DELETE FROM sessions WHERE token_hash=?`), hashKey(token)) +} + +// DeleteUserSessions 吊销用户全部会话(改密/重置后);except 非空时保留该会话。 +func (s *Store) DeleteUserSessions(owner, exceptToken string) { + if exceptToken != "" { + s.db.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND token_hash<>?`), owner, hashKey(exceptToken)) + return + } + s.db.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner) +} + +// ListUsersAdmin 管理员用户列表(按创建序)。 +func (s *Store) ListUsersAdmin() ([]AdminUser, error) { + rows, err := s.db.Query(s.q(`SELECT owner,COALESCE(name,''),password_hash,must_change_password,is_admin,password_set_at,created_at FROM users ORDER BY created_at`)) + if err != nil { + return nil, err + } + defer rows.Close() + out := []AdminUser{} + for rows.Next() { + var u AdminUser + var hash, psa, ca string + var mustChange, isAdmin int + if err := rows.Scan(&u.Owner, &u.Name, &hash, &mustChange, &isAdmin, &psa, &ca); err != nil { + return nil, err + } + u.MustChangePassword = mustChange == 1 + u.IsAdmin = isAdmin == 1 + u.HasPassword = hash != "" + if psa != "" { + t, _ := time.Parse(time.RFC3339Nano, psa) + u.PasswordSetAt = &t + } + u.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca) + out = append(out, u) + } + return out, rows.Err() +} diff --git a/internal/store/migrate.go b/internal/store/migrate.go index b0901ad..bef8d32 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -262,6 +262,37 @@ func init() { `ALTER TABLE conversations DROP COLUMN archived`, `ALTER TABLE conversations DROP COLUMN pinned`, }}}, + migration{version: 10, name: "auth_accounts", + // 管理员发放制(2026-09-30 用户裁定):无自助注册;初始/重置密码后 + // must_change_password=1,首次登录强制改密;会话为服务端状态(HttpOnly cookie)。 + up: map[string][]string{"sqlite": { + `ALTER TABLE users ADD COLUMN password_hash TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE users ADD COLUMN password_set_at TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE users ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0`, + `CREATE TABLE IF NOT EXISTS sessions (token_hash TEXT PRIMARY KEY, owner TEXT NOT NULL, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, last_seen_at TEXT NOT NULL DEFAULT '')`, + `CREATE INDEX IF NOT EXISTS idx_sessions_owner ON sessions(owner)`, + }, "postgres": { + `ALTER TABLE users ADD COLUMN password_hash TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE users ADD COLUMN password_set_at TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE users ADD COLUMN must_change_password BOOLEAN NOT NULL DEFAULT FALSE`, + `ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE`, + `CREATE TABLE IF NOT EXISTS sessions (token_hash TEXT PRIMARY KEY, owner TEXT NOT NULL, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, last_seen_at TEXT NOT NULL DEFAULT '')`, + `CREATE INDEX IF NOT EXISTS idx_sessions_owner ON sessions(owner)`, + }}, + down: map[string][]string{"sqlite": { + `DROP TABLE IF EXISTS sessions`, + `CREATE TABLE users_old (id TEXT PRIMARY KEY, owner TEXT NOT NULL UNIQUE, name TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL)`, + `INSERT INTO users_old(id, owner, name, created_at) SELECT id, owner, name, created_at FROM users`, + `DROP TABLE users`, + `ALTER TABLE users_old RENAME TO users`, + }, "postgres": { + `DROP TABLE IF EXISTS sessions`, + `ALTER TABLE users DROP COLUMN is_admin`, + `ALTER TABLE users DROP COLUMN must_change_password`, + `ALTER TABLE users DROP COLUMN password_set_at`, + `ALTER TABLE users DROP COLUMN password_hash`, + }}}, ) } diff --git a/internal/store/store.go b/internal/store/store.go index 2c04322..b805841 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -25,6 +25,7 @@ import ( ) var ErrNotFound = errors.New("store: not found") +var ErrConflict = errors.New("store: conflict") // OwnerDefault 是单租户默认 owner(嵌入式档 / 未启用认证时的隐式身份)。 const OwnerDefault = "local" diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 8709d11..dfd90db 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -56,8 +56,8 @@ func TestMigrationUpgradesExistingDatabase(t *testing.T) { if err != nil { t.Fatal(err) } - if len(got) != 9 { - t.Fatalf("expected versions [1..9], got %v", got) + if len(got) != 10 { + t.Fatalf("expected versions [1..10], got %v", got) } // 老库形态(v2 前):events 全局自增、settings 单列主键。 if err := st.MigrateDown(1); err != nil { @@ -451,3 +451,74 @@ func TestConversationFlagsAndTaskList(t *testing.T) { t.Fatalf("task list must be tenant-scoped, got %d", len(got)) } } + +// TestAdminAccountsAndSessions 管理员发放制(R2):建号/哈希/首登改密标志/会话生命周期。 +func TestAdminAccountsAndSessions(t *testing.T) { + st := openTestStore(t) + hash, err := HashPassword("initial-pass-1") + if err != nil { + t.Fatal(err) + } + if !VerifyPassword(hash, "initial-pass-1") || VerifyPassword(hash, "wrong") { + t.Fatal("password hash/verify broken") + } + if VerifyPassword(HashPasswordMust(t, "dup"), "dup2") { + t.Fatal("different passwords must not collide") + } + if err := st.CreateAccount("admin", "管理员", hash, true); err != nil { + t.Fatal(err) + } + // owner 重复 → Conflict。 + if err := st.CreateAccount("admin", "", hash, false); err != ErrConflict { + t.Fatalf("duplicate owner must conflict, got %v", err) + } + st0, ok := st.VerifyLogin("admin", "initial-pass-1") + if !ok || !st0.MustChangePassword || !st0.IsAdmin { + t.Fatalf("initial login state: %+v ok=%v", st0, ok) + } + // 错误密码拒绝。 + if _, ok := st.VerifyLogin("admin", "nope"); ok { + t.Fatal("wrong password must fail") + } + // 会话:建 → 验 → 改密后吊销其余。 + tok, err := st.CreateSession("admin") + if err != nil { + t.Fatal(err) + } + if owner, ok := st.SessionOwner(tok); !ok || owner != "admin" { + t.Fatalf("session validate: %q %v", owner, ok) + } + hash2 := HashPasswordMust(t, "changed-pass-2") + if err := st.SetUserPassword("admin", hash2); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword("admin"); err != nil { + t.Fatal(err) + } + st1, _ := st.GetUserAuthState("admin") + if st1.MustChangePassword { + t.Fatal("must_change should clear after self-change") + } + st.DeleteUserSessions("admin", tok) + if _, ok := st.SessionOwner(tok); !ok { + t.Fatal("kept session (except-path) must survive") + } + st.DeleteUserSessions("admin", "") + if _, ok := st.SessionOwner(tok); ok { + t.Fatal("full delete must remove sessions") + } + // 管理员列表可见。 + users, err := st.ListUsersAdmin() + if err != nil || len(users) == 0 { + t.Fatalf("admin list: %v %d", err, len(users)) + } +} + +func HashPasswordMust(t *testing.T, pw string) string { + t.Helper() + h, err := HashPassword(pw) + if err != nil { + t.Fatal(err) + } + return h +} diff --git a/internal/tools/http_test.go b/internal/tools/http_test.go index e0968ab..00de5f8 100644 --- a/internal/tools/http_test.go +++ b/internal/tools/http_test.go @@ -10,8 +10,8 @@ import ( type dummyCtx struct{ project string } -func (dummyCtx) ProjectID() string { return "" } -func (dummyCtx) ConversationID() string { return "" } +func (dummyCtx) ProjectID() string { return "" } +func (dummyCtx) ConversationID() string { return "" } func (dummyCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) { return nil, nil } func (dummyCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) { return core.Artifact{}, nil diff --git a/internal/tools/registry.go b/internal/tools/registry.go index e0b6e14..019bc1e 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -90,7 +90,7 @@ func (r *Registry) Execute(rc ToolExecCtx, call core.ToolCallRequest) core.ToolC return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()} } _ = desc - return core.ToolCallResult{CallID: call.CallID, Status: "ok", Output: out, Truncated: false, Artifact: nil, } + return core.ToolCallResult{CallID: call.CallID, Status: "ok", Output: out, Truncated: false, Artifact: nil} } // ---- 内置最小工具集(一切可禁用:画像三清单控制)---- @@ -109,7 +109,10 @@ func (r *Registry) RegisterBuiltin() error { Name: "knowledge_search", Description: "在项目知识与插件知识库中做全文检索,返回命中片段", Source: core.SourceBuiltin, Parameters: json.RawMessage(`{"type":"object","properties":{"query":{"type":"string","description":"检索词"}},"required":["query"]}`), }, func(rc ToolExecCtx, args json.RawMessage) (string, error) { - var a struct{ Query string `json:"query"`; TopK int `json:"topK"` } + var a struct { + Query string `json:"query"` + TopK int `json:"topK"` + } if len(args) > 0 { if err := json.Unmarshal(args, &a); err != nil { return "", fmt.Errorf("knowledge_search: bad args: %w", err)