Files
Agent-System-Backend/internal/server/auth_e2e_test.go
T
weichen 6681c3b47c
ci / vet-and-test (pull_request) Successful in 44s
Fall back to session cookie on stale Bearer key and report authVia
Why: a browser holding a revoked API key in localStorage sent the Bearer
header on every request; key precedence 401'd all APIs despite a valid
login session, locking admins out of user management. /api/me also
reported mustChangePassword for key principals, who cannot change
passwords (session-owned), dead-ending the forced-change gate.

Evidence: new TestStaleKeySessionFallback covers fallback and authVia;
TestAuthMiddleware/TestAdminAccountFlow unchanged and green; full
go test ./... green.

Impact: invalid key falls back to a valid session cookie before 401
(cookie-less programmatic clients unaffected); /api/me gains
authVia ("key"|"session"|""), letting the frontend keep key holders
out of the change-password gate.

Refs: Dev task feature/admin-auth-gate-fix.
2026-10-11 13:45:52 +00:00

496 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
// 认证与多租户 e2e(阶段 1):API key 中间件、租户隔离(404 负例)、
// 会话在途互斥(409)、备份端点、SSE 越权拒绝。
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"agent-system-backend/internal/bus"
"agent-system-backend/internal/plugins"
"agent-system-backend/internal/profiles"
"agent-system-backend/internal/runmgr"
"agent-system-backend/internal/runtime"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/internal/tools"
core "agent-system-core"
)
// newAuthTestServer:开启 API key 认证的组合根(测试版)。
func newAuthTestServer(t *testing.T) (*Server, *store.Store) {
t.Helper()
dir := t.TempDir()
st, err := store.OpenSQLite(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
b := bus.New()
reg := tools.NewRegistry()
reg.RegisterBuiltin()
setSvc := settings.NewService(st)
profSvc := profiles.New(st)
profSvc.EnsureDefault()
mgr := runmgr.New(st, b, reg, profSvc, setSvc, &runtime.LLMRunner{}, &runtime.SubLLMRunner{}, &runtime.MockRunner{}, &runtime.MockSubagent{})
ph := plugins.New(st, reg, setSvc, filepath.Join(dir, "plugins"))
srv := New(st, mgr, reg, profSvc, setSvc, ph)
srv.Auth = NewAuthenticator(st, true)
srv.Subscribe = func(cid string) (<-chan core.StreamEvent, func()) { return b.Subscribe(cid) }
return srv, st
}
func TestAuthMiddleware(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
// 未带 key:401。
req := httptest.NewRequest("GET", "/api/projects", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("no key: %d", rec.Code)
}
// 创建密钥。
key, err := st.CreateAPIKey("alice", "e2e")
if err != nil {
t.Fatal(err)
}
// Bearer 头:200。
req = httptest.NewRequest("GET", "/api/projects", nil)
req.Header.Set("Authorization", "Bearer "+key)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("bearer: %d", rec.Code)
}
// ?key= 查询参数(EventSource 路径):200。
req = httptest.NewRequest("GET", "/api/projects?key="+key, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("query key: %d", rec.Code)
}
// 撤销后 401。
if ok, err := st.RevokeAPIKeyByName("alice", "e2e"); err != nil || !ok {
t.Fatalf("revoke: %v %v", ok, err)
}
req = httptest.NewRequest("GET", "/api/projects", nil)
req.Header.Set("Authorization", "Bearer "+key)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("revoked: %d", rec.Code)
}
}
// 租户隔离(HTTP 负例):alice 的资源对 bob 一律 404。
func TestTenantIsolationHTTP(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
keyA, _ := st.CreateAPIKey("alice", "a")
keyB, _ := st.CreateAPIKey("bob", "b")
do := func(key, method, path string, body any) int {
var buf bytes.Buffer
if body != nil {
json.NewEncoder(&buf).Encode(body)
}
req := httptest.NewRequest(method, path, &buf)
req.Header.Set("Authorization", "Bearer "+key)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec.Code
}
if c := do(keyA, "POST", "/api/projects", map[string]any{"name": "A"}); c != 200 {
t.Fatalf("alice create: %d", c)
}
var projects []map[string]any
req := httptest.NewRequest("GET", "/api/projects", nil)
req.Header.Set("Authorization", "Bearer "+keyA)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
json.Unmarshal(rec.Body.Bytes(), &projects)
if len(projects) != 1 {
t.Fatalf("alice projects = %v", projects)
}
projectID := projects[0]["id"].(string)
// bob 看不到 alice 的项目/会话/工件。
if c := do(keyB, "PATCH", "/api/projects/"+projectID, map[string]any{"name": "劫持"}); c != 404 {
t.Fatalf("bob patch alice project: %d", c)
}
if c := do(keyB, "GET", "/api/nonexistent", nil); c != 404 {
t.Fatalf("unknown api path must 404, got %d", c)
}
if c := do(keyB, "GET", "/api/projects/"+projectID+"/conversations", nil); c != 404 {
t.Fatalf("bob list alice conversations: %d", c)
}
if c := do(keyB, "DELETE", "/api/projects/"+projectID, nil); c != 404 {
t.Fatalf("bob delete alice project: %d", c)
}
// bob 项目列表为空(不是 404,是空数组)。
req = httptest.NewRequest("GET", "/api/projects", nil)
req.Header.Set("Authorization", "Bearer "+keyB)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
var bobProjects []map[string]any
json.Unmarshal(rec.Body.Bytes(), &bobProjects)
if len(bobProjects) != 0 {
t.Fatalf("bob projects = %v", bobProjects)
}
// bob 不能在 alice 项目下建会话。
if c := do(keyB, "POST", "/api/projects/"+projectID+"/conversations", map[string]any{"title": "x"}); c != 404 {
t.Fatalf("bob create conversation in alice project: %d", c)
}
// 设置按租户隔离。
if c := do(keyA, "PUT", "/api/settings/user", map[string]any{"ui": map[string]any{"theme": "light"}}); c != 200 {
t.Fatalf("alice settings: %d", c)
}
req = httptest.NewRequest("GET", "/api/settings", nil)
req.Header.Set("Authorization", "Bearer "+keyB)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
var s map[string]any
json.Unmarshal(rec.Body.Bytes(), &s)
if u := s["user"].(map[string]any); len(u) != 0 {
t.Fatalf("bob user settings must be empty, got %v", u)
}
}
// 会话在途互斥:mock run 进行中(阻塞 runner)第二个消息 409。
func TestConversationBusyConflict(t *testing.T) {
dir := t.TempDir()
st, err := store.OpenSQLite(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
b := bus.New()
reg := tools.NewRegistry()
reg.RegisterBuiltin()
setSvc := settings.NewService(st)
profSvc := profiles.New(st)
profSvc.EnsureDefault()
block := make(chan struct{}, 1) // 缓冲:通知不阻塞执行
release := make(chan struct{})
// 阻塞 runner:第一个 run 挂住直到 release。
blocking := blockingRunner{block: block, release: release}
mgr := runmgr.New(st, b, reg, profSvc, setSvc, blocking, blocking, &runtime.MockRunner{}, &runtime.MockSubagent{})
ph := plugins.New(st, reg, setSvc, filepath.Join(dir, "plugins"))
srv := New(st, mgr, reg, profSvc, setSvc, ph)
h := srv.Handler()
// 建项目与会话(认证关闭,默认租户)。
_, out := doJSON(t, h, "POST", "/api/projects", map[string]any{"name": "p"})
projectID := out["id"].(string)
_, out = doJSON(t, h, "POST", "/api/projects/"+projectID+"/conversations", map[string]any{"title": "c"})
convID := out["id"].(string)
msg := core.Message{ConversationID: convID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "hi"}}}
conv, _ := st.GetConversation(store.OwnerDefault, convID)
if _, err := mgr.StartConversationRun(conv, msg, nil, nil); err != nil {
t.Fatal(err)
}
// 第二个 run:409。
if _, err := mgr.StartConversationRun(conv, msg, nil, nil); err != runmgr.ErrConversationBusy {
t.Fatalf("second run must be busy, got %v", err)
}
// 释放第一个 run,槽位归还后可再启动。
close(release)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
_, err := mgr.StartConversationRun(conv, msg, nil, nil)
if err == nil {
return // 槽位已归还:通过
}
if err != runmgr.ErrConversationBusy {
t.Fatalf("unexpected: %v", err)
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("conversation slot not released after run finished")
}
type blockingRunner struct {
block chan struct{}
release chan struct{}
}
func (r blockingRunner) Name() string { return "blocking" }
func (r blockingRunner) Execute(rc runtime.RunCtx) error {
rc.SetAssistantParts([]core.Part{{Type: core.PartText, Text: "done"}})
r.block <- struct{}{} // 通知已到位
<-r.release // 等待释放
return nil
}
// 备份端点:产出一致性副本文件。
func TestBackupEndpoint(t *testing.T) {
srv, _ := newTestServer(t)
h := srv.Handler()
code, out := doJSON(t, h, "POST", "/api/admin/backup", nil)
if code != 200 {
t.Fatalf("backup: %d %v", code, out)
}
path := out["path"].(string)
if !strings.HasSuffix(path, ".db") {
t.Fatalf("backup path: %s", path)
}
if _, err := os.Stat(path); err != nil {
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)
}
}
// TestStaleKeySessionFallback:浏览器残留失效 Bearer key 时,有效会话不被一票否决;
// /api/me 的 authVia 区分凭证通道(key / session),供前端区分改密门放行范围。
func TestStaleKeySessionFallback(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
hash, err := store.HashPassword("init-pass-01")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("carol", "卡罗", hash, false); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"carol","password":"init-pass-01"}`))
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())
}
tok := strings.Split(strings.Split(rec.Header().Get("Set-Cookie"), SessionCookie+"=")[1], ";")[0]
// 会话 + 失效 key:回落会话(200,authVia=session),不再被 key 一票否决。
req = httptest.NewRequest("GET", "/api/me", nil)
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: tok})
req.Header.Set("Authorization", "Bearer ask_stale_key")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"authVia":"session"`) {
t.Fatalf("stale key + valid session must fall back to session: %d %s", rec.Code, rec.Body.String())
}
// 失效 key 且无会话:仍 401(key 专属文案)。
req = httptest.NewRequest("GET", "/api/me", nil)
req.Header.Set("Authorization", "Bearer ask_stale_key")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized || !strings.Contains(rec.Body.String(), "invalid or revoked API key") {
t.Fatalf("stale key alone must 401 with key error: %d %s", rec.Code, rec.Body.String())
}
// 有效 key:authVia=key。
key, err := st.CreateAPIKey("carol", "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 || !strings.Contains(rec.Body.String(), `"authVia":"key"`) {
t.Fatalf("me via key: %d %s", rec.Code, rec.Body.String())
}
// 纯会话:authVia=session。
req = httptest.NewRequest("GET", "/api/me", nil)
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: tok})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"authVia":"session"`) {
t.Fatalf("me via session: %d %s", rec.Code, rec.Body.String())
}
}