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)
This commit is contained in:
2026-09-30 11:15:22 +00:00
parent 96f194562a
commit 9a545b1e69
28 changed files with 1007 additions and 57 deletions
+64
View File
@@ -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 <create|reset-password|list> [--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
}
}
+64
View File
@@ -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
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -13,8 +13,8 @@ import (
"strings"
"time"
pdflib "github.com/ledongthuc/pdf"
core "agent-system-core"
pdflib "github.com/ledongthuc/pdf"
)
// Parse 按扩展名分派内置解析器;无法解析时返回带 warning 的空文档(显式失败,不静默)。
+3 -3
View File
@@ -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
}
+1 -1
View File
@@ -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{"示例", "演示"}},
})
}
+2 -2
View File
@@ -2,7 +2,7 @@
// 落盘事务提交后 NOTIFY agent_events '<conv>|<lastSeq>';每实例一个 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"
)
+1 -1
View File
@@ -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) {
+12 -12
View File
@@ -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 {
+2 -2
View File
@@ -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)
}
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -4,8 +4,8 @@ import (
"strings"
"testing"
core "agent-system-core"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// 内置库必须全部可解析、ID 唯一、层级合法、触发引用完整。
+1 -1
View File
@@ -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 确定性向量:按首字符映射,验证混合检索排序无需真实模型。
+1 -1
View File
@@ -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 是会话事件批处理器(写放大治理):
+5 -5
View File
@@ -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 段(基座+环境+命中技能),并产出可观测条目。
+1 -1
View File
@@ -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)
+281
View File
@@ -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)
}
+32 -7
View File
@@ -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 <api key>(程序化;?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 <key>)", 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)
}
+171
View File
@@ -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)
}
}
+18 -1
View File
@@ -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
+8 -8
View File
@@ -3,13 +3,13 @@
package settings
import (
core "agent-system-core"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// Scope 层级键:user 与 project:<id> 落库;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"},
}
}
+222
View File
@@ -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()
}
+31
View File
@@ -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`,
}}},
)
}
+1
View File
@@ -25,6 +25,7 @@ import (
)
var ErrNotFound = errors.New("store: not found")
var ErrConflict = errors.New("store: conflict")
// OwnerDefault 是单租户默认 owner(嵌入式档 / 未启用认证时的隐式身份)。
const OwnerDefault = "local"
+73 -2
View File
@@ -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
}
+2 -2
View File
@@ -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
+5 -2
View File
@@ -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)