From 04c8c54ff8ba3d483094bef6f72e4e8ed8ff29b4 Mon Sep 17 00:00:00 2001
From: ji ruo <79234113+RuoJi6@users.noreply.github.com>
Date: Fri, 9 Oct 2026 10:11:57 +0800
Subject: [PATCH] feat(auth): add optional HTTP Basic Auth access gate
---
README.md | 19 +
server/auth.go | 4 +-
server/basic_auth.go | 220 ++++++++++++
server/basic_auth_test.go | 328 ++++++++++++++++++
server/server.go | 36 +-
.../tasks/detail/_tabs/sessions-tab.tsx | 6 +-
web/src/app/(main)/system/logs/page.tsx | 4 +-
.../settings/_components/basic-auth-card.tsx | 132 +++++++
.../settings/_components/update-card.tsx | 4 +-
web/src/app/(main)/system/settings/page.tsx | 2 +
web/src/hooks/use-side-questions.ts | 4 +-
web/src/lib/api.ts | 12 +
web/src/lib/mock/handler.ts | 2 +
web/src/proxy.ts | 59 +++-
14 files changed, 809 insertions(+), 23 deletions(-)
create mode 100644 server/basic_auth.go
create mode 100644 server/basic_auth_test.go
create mode 100644 web/src/app/(main)/system/settings/_components/basic-auth-card.tsx
diff --git a/README.md b/README.md
index 397121c..1542948 100644
--- a/README.md
+++ b/README.md
@@ -279,6 +279,25 @@ CGO_ENABLED=0 go build -tags embedui -o artex ./cmd/artex
**常用参数**:`./start.sh -addr :8787 -proxy :8788`(`-addr` 前端+API,`-proxy` 流量录制代理)。启动脚本会把参数原样透传给 `artex`。
+### 可选的 HTTP Basic Auth 访问验证
+
+在 **系统配置 → HTTP Basic Auth** 中填写独立的验证用户名和密码,打开「启用访问验证」并点击「保存访问验证」。默认关闭,保存后立即生效,无需重启;密码至少 8 位、最多 72 字节,用户名不能包含冒号。
+
+开启后,访客先完成浏览器原生账号密码验证,再进入原有 ARTEX 登录页。网页、静态资源和 API 均受保护,`/api/health` 健康检查保留公开访问;通过访问验证仍需登录 ARTEX 才能使用业务 API。公网部署请通过 HTTPS 访问。
+
+配置持久化到数据库,密码仅保存 bcrypt 哈希,界面不会回显。保存时密码留空表示不修改;关闭后保留凭据,方便再次开启。独立的 HttpOnly 验证 Cookie 有效期为 12 小时,兼容现有 JWT 和 SSE;保存配置会使其他浏览器的旧验证 Cookie 失效,当前管理员浏览器保持访问。浏览器可能缓存 Basic Auth 凭据,可用新的无痕窗口检查验证弹窗。
+
+Docker 和内嵌前端二进制直接支持此功能。`next dev` 的认证检查仅向本机回环后端转发凭据(`localhost`、`127.0.0.1` 或 `::1`);前端与后端应使用同一主机名。远程开发后端未开启验证时仍可使用;开启后请通过内嵌前端访问远程服务,避免跨域验证 Cookie 问题。
+
+若遗忘独立验证密码,可在服务器连接应用数据库后执行以下语句并重启 ARTEX,关闭这层访问验证,再用原有 ARTEX 账号登录后重新配置:
+
+```sql
+UPDATE settings
+SET value = jsonb_set(value::jsonb, '{enabled}', 'false'::jsonb)::text,
+ updated_at = now()
+WHERE key = 'auth.http_basic';
+```
+
### 反向代理部署(HTTPS / 只开放 443)
前端和 API/SSE 都由同一个后端端口(默认 `:8787`)提供,实时活动流默认走**同源**地址,因此**无需配置 `NEXT_PUBLIC_SSE_BASE`**,公网只开放 443、把 8787 留在内网即可。
diff --git a/server/auth.go b/server/auth.go
index b0a6b09..00368ce 100644
--- a/server/auth.go
+++ b/server/auth.go
@@ -116,11 +116,11 @@ func extractToken(r *http.Request) string {
}
// requireAuth wraps h with JWT validation.
-// /api/auth/* and /api/health are exempt.
+// /api/auth/*, /api/health and the Basic Auth gate check are exempt.
func (s *Server) requireAuth(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
- if strings.HasPrefix(p, "/api/auth/") || p == "/api/health" {
+ if strings.HasPrefix(p, "/api/auth/") || p == "/api/health" || p == "/api/basic-auth/check" {
h.ServeHTTP(w, r)
return
}
diff --git a/server/basic_auth.go b/server/basic_auth.go
new file mode 100644
index 0000000..685f72d
--- /dev/null
+++ b/server/basic_auth.go
@@ -0,0 +1,220 @@
+package server
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+ "unicode"
+
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/google/uuid"
+ "golang.org/x/crypto/bcrypt"
+)
+
+const basicAuthKey = "auth.http_basic"
+const basicAuthCookie = "artex_basic_auth"
+const basicAuthTTL = 12 * time.Hour
+
+type basicAuthConfig struct {
+ Enabled bool `json:"enabled"`
+ Username string `json:"username"`
+ PasswordHash string `json:"password_hash"`
+ Revision string `json:"revision"`
+}
+
+type basicAuthSettingsStore interface {
+ GetSetting(string) (string, bool, error)
+ SetSetting(string, string) error
+}
+
+// Keep one atomic configuration in the DB and publish it only after a successful
+// save. Requests use the in-memory snapshot, avoiding a DB read for every asset.
+type basicAuthGate struct {
+ mu sync.RWMutex
+ cfg basicAuthConfig
+ store basicAuthSettingsStore
+}
+
+func (g *basicAuthGate) load(store basicAuthSettingsStore) error {
+ raw, ok, err := store.GetSetting(basicAuthKey)
+ if err != nil {
+ return err
+ }
+ var cfg basicAuthConfig
+ if ok {
+ if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
+ return fmt.Errorf("invalid HTTP Basic Auth configuration")
+ }
+ }
+ if cfg.Enabled {
+ if !validBasicUsername(cfg.Username) || cfg.Revision == "" {
+ return fmt.Errorf("incomplete HTTP Basic Auth configuration")
+ }
+ if _, err := bcrypt.Cost([]byte(cfg.PasswordHash)); err != nil {
+ return fmt.Errorf("invalid HTTP Basic Auth password hash")
+ }
+ }
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ g.cfg, g.store = cfg, store
+ return nil
+}
+
+func (g *basicAuthGate) snapshot() basicAuthConfig {
+ g.mu.RLock()
+ defer g.mu.RUnlock()
+ return g.cfg
+}
+
+func validBasicUsername(username string) bool {
+ return strings.TrimSpace(username) != "" && len(username) <= 128 &&
+ !strings.Contains(username, ":") && strings.IndexFunc(username, unicode.IsControl) == -1
+}
+
+// A separate signing key prevents a gate cookie from being used as an admin JWT.
+func (s *Server) basicAuthSigningKey() []byte {
+ m := hmac.New(sha256.New, s.jwtKey)
+ m.Write([]byte("artex/http-basic-auth/session/v1"))
+ return m.Sum(nil)
+}
+
+func (s *Server) validBasicAuthCookie(r *http.Request, cfg basicAuthConfig) bool {
+ cookie, err := r.Cookie(basicAuthCookie)
+ if err != nil || cfg.Revision == "" {
+ return false
+ }
+ claims := &jwt.RegisteredClaims{}
+ _, err = jwt.ParseWithClaims(cookie.Value, claims, func(*jwt.Token) (any, error) {
+ return s.basicAuthSigningKey(), nil
+ }, jwt.WithValidMethods([]string{"HS256"}), jwt.WithExpirationRequired(),
+ jwt.WithAudience("artex-basic-auth"), jwt.WithSubject(cfg.Revision))
+ return err == nil
+}
+
+func (s *Server) setBasicAuthCookie(w http.ResponseWriter, r *http.Request, cfg basicAuthConfig) error {
+ cookie := &http.Cookie{
+ Name: basicAuthCookie, Path: "/", HttpOnly: true,
+ Secure: r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https"),
+ SameSite: http.SameSiteStrictMode, MaxAge: -1,
+ }
+ if cfg.Enabled {
+ now := time.Now()
+ token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
+ Subject: cfg.Revision, Audience: jwt.ClaimStrings{"artex-basic-auth"},
+ IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(basicAuthTTL)),
+ }).SignedString(s.basicAuthSigningKey())
+ if err != nil {
+ return err
+ }
+ cookie.Value, cookie.MaxAge = token, int(basicAuthTTL.Seconds())
+ }
+ http.SetCookie(w, cookie)
+ return nil
+}
+
+func (s *Server) requireBasicAuth(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ cfg := s.basicAuth.snapshot()
+ // Health probes stay public; CORS preflight contains no credentials and
+ // the API CORS handler returns 204 without invoking any business handler.
+ if !cfg.Enabled || (r.URL.Path == "/api/health" && (r.Method == "GET" || r.Method == "HEAD")) ||
+ (r.Method == "OPTIONS" && strings.HasPrefix(r.URL.Path, "/api/")) {
+ next.ServeHTTP(w, r)
+ return
+ }
+ w.Header().Set("Cache-Control", "no-store")
+ if s.validBasicAuthCookie(r, cfg) {
+ next.ServeHTTP(w, r)
+ return
+ }
+ username, password, ok := r.BasicAuth()
+ if !ok || len(password) > maxPasswordBytes || username != cfg.Username ||
+ bcrypt.CompareHashAndPassword([]byte(cfg.PasswordHash), []byte(password)) != nil {
+ w.Header().Set("WWW-Authenticate", `Basic realm="ARTEX", charset="UTF-8"`)
+ writeErr(w, http.StatusUnauthorized, "请先完成 HTTP Basic Auth 验证")
+ return
+ }
+ // Browser API requests already use Authorization: Bearer. A separate
+ // HttpOnly cookie keeps the gate satisfied without replacing that header.
+ if err := s.setBasicAuthCookie(w, r, cfg); err != nil {
+ writeErr(w, 500, "认证会话生成失败")
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+func basicAuthPublicSettings(cfg basicAuthConfig) map[string]any {
+ return map[string]any{"enabled": cfg.Enabled, "username": cfg.Username, "password_set": cfg.PasswordHash != ""}
+}
+
+func (s *Server) getBasicAuthSettings(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "no-store")
+ writeJSON(w, 200, basicAuthPublicSettings(s.basicAuth.snapshot()))
+}
+
+func (s *Server) saveBasicAuthSettings(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Enabled *bool `json:"enabled"`
+ Username *string `json:"username"`
+ Password string `json:"password"`
+ }
+ if err := decode(r, &req); err != nil {
+ writeErr(w, 400, "请求格式错误")
+ return
+ }
+ g := &s.basicAuth
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ if g.store == nil {
+ writeErr(w, 503, errDataSourceUnavailable)
+ return
+ }
+ cfg := g.cfg
+ if req.Enabled != nil {
+ cfg.Enabled = *req.Enabled
+ }
+ if req.Username != nil {
+ cfg.Username = strings.TrimSpace(*req.Username)
+ }
+ if (cfg.Enabled || cfg.Username != "") && !validBasicUsername(cfg.Username) {
+ writeErr(w, 400, "Basic Auth 用户名不能为空、超过 128 字节或包含冒号及控制字符")
+ return
+ }
+ if req.Password != "" {
+ if msg := validatePassword(req.Password); msg != "" {
+ writeErr(w, 400, msg)
+ return
+ }
+ hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
+ if err != nil {
+ writeErr(w, 500, "密码加密失败")
+ return
+ }
+ cfg.PasswordHash = string(hash)
+ }
+ if cfg.Enabled && cfg.PasswordHash == "" {
+ writeErr(w, 400, "请先设置 Basic Auth 密码")
+ return
+ }
+ // Every save revokes previous gate cookies, including disable/re-enable.
+ cfg.Revision = uuid.NewString()
+ data, err := json.Marshal(cfg)
+ if err != nil || g.store.SetSetting(basicAuthKey, string(data)) != nil {
+ writeErr(w, 503, "Basic Auth 配置保存失败,请重试")
+ return
+ }
+ g.cfg = cfg
+ w.Header().Set("Cache-Control", "no-store")
+ // Keep the administrator who just saved the settings in their session.
+ if err := s.setBasicAuthCookie(w, r, cfg); err != nil {
+ writeErr(w, 500, "认证会话生成失败")
+ return
+ }
+ writeJSON(w, 200, basicAuthPublicSettings(cfg))
+}
diff --git a/server/basic_auth_test.go b/server/basic_auth_test.go
new file mode 100644
index 0000000..5f1443d
--- /dev/null
+++ b/server/basic_auth_test.go
@@ -0,0 +1,328 @@
+package server
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+ "golang.org/x/crypto/bcrypt"
+)
+
+type basicAuthMemoryStore struct {
+ raw string
+ err error
+}
+
+func (m *basicAuthMemoryStore) GetSetting(string) (string, bool, error) {
+ return m.raw, m.raw != "", m.err
+}
+func (m *basicAuthMemoryStore) SetSetting(_, value string) error {
+ if m.err != nil {
+ return m.err
+ }
+ m.raw = value
+ return nil
+}
+
+func basicAuthTestServer(t *testing.T) (*Server, *basicAuthMemoryStore) {
+ t.Helper()
+ store := &basicAuthMemoryStore{}
+ s := &Server{jwtKey: []byte("test-only-signing-key-32-characters")}
+ if err := s.basicAuth.load(store); err != nil {
+ t.Fatal(err)
+ }
+ return s, store
+}
+
+func saveBasicAuthForTest(s *Server, body string) *httptest.ResponseRecorder {
+ w := httptest.NewRecorder()
+ s.saveBasicAuthSettings(w, httptest.NewRequest("PUT", "/api/settings/basic-auth", strings.NewReader(body)))
+ return w
+}
+
+func TestBasicAuthGate(t *testing.T) {
+ s, _ := basicAuthTestServer(t)
+ h := s.requireBasicAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) }))
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, httptest.NewRequest("GET", "/login", nil))
+ if w.Code != 204 {
+ t.Fatal("must default to disabled")
+ }
+ w = saveBasicAuthForTest(s, `{"enabled":true,"username":"entry","password":"密码:test-pass"}`)
+ if w.Code != 200 {
+ t.Fatalf("enable: %s", w.Body.String())
+ }
+ cookie := w.Result().Cookies()[0]
+ if !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode || cookie.Path != "/" {
+ t.Fatalf("unsafe cookie: %+v", cookie)
+ }
+ if verifyJWT(cookie.Value, s.jwtKey) {
+ t.Fatal("gate cookie accepted as admin JWT")
+ }
+ adminJWT, _ := signJWT(s.jwtKey)
+ for _, tc := range []struct {
+ name, path, user, pass, bearer string
+ cookie *http.Cookie
+ want int
+ }{
+ {name: "page challenge", path: "/", want: 401},
+ {name: "login challenge", path: "/login", want: 401},
+ {name: "asset challenge", path: "/_next/static/app.js", want: 401},
+ {name: "auth API challenge", path: "/api/auth/status", want: 401},
+ {name: "check challenge", path: "/api/basic-auth/check", want: 401},
+ {name: "health exempt", path: "/api/health", want: 204},
+ {name: "health prefix not exempt", path: "/api/health/anything", want: 401},
+ {name: "wrong password", path: "/login", user: "entry", pass: "wrong", want: 401},
+ {name: "wrong user", path: "/login", user: "wrong", pass: "密码:test-pass", want: 401},
+ {name: "correct Unicode credentials", path: "/login", user: "entry", pass: "密码:test-pass", want: 204},
+ {name: "gate cookie and bearer", path: "/api/tasks", cookie: cookie, bearer: adminJWT, want: 204},
+ {name: "admin JWT cannot bypass gate", path: "/api/tasks", bearer: adminJWT, want: 401},
+ {name: "tampered cookie", path: "/login", cookie: &http.Cookie{Name: basicAuthCookie, Value: cookie.Value + "x"}, want: 401},
+ {name: "admin JWT is not gate cookie", path: "/login", cookie: &http.Cookie{Name: basicAuthCookie, Value: adminJWT}, want: 401},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ r := httptest.NewRequest("GET", tc.path, nil)
+ if tc.user != "" {
+ r.SetBasicAuth(tc.user, tc.pass)
+ }
+ if tc.cookie != nil {
+ r.AddCookie(tc.cookie)
+ }
+ if tc.bearer != "" {
+ r.Header.Set("Authorization", "Bearer "+tc.bearer)
+ }
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ if w.Code != tc.want {
+ t.Fatalf("status=%d want %d: %s", w.Code, tc.want, w.Body.String())
+ }
+ if w.Code == 401 && w.Header().Get("WWW-Authenticate") != `Basic realm="ARTEX", charset="UTF-8"` {
+ t.Fatal("missing browser challenge")
+ }
+ })
+ }
+ // Passing the outer gate must not grant access to the inner admin API.
+ r := httptest.NewRequest("GET", "/api/settings/basic-auth", nil)
+ r.AddCookie(cookie)
+ w = httptest.NewRecorder()
+ s.Handler().ServeHTTP(w, r)
+ if w.Code != 401 {
+ t.Fatal("gate cookie bypassed admin authentication")
+ }
+ r.Header.Set("Authorization", "Bearer "+adminJWT)
+ w = httptest.NewRecorder()
+ s.Handler().ServeHTTP(w, r)
+ if w.Code != 200 {
+ t.Fatalf("gate + JWT rejected: %s", w.Body.String())
+ }
+}
+
+func TestBasicAuthSettingsPersistenceAndRevocation(t *testing.T) {
+ s, store := basicAuthTestServer(t)
+ w := saveBasicAuthForTest(s, `{"enabled":true,"username":"entry","password":"test-pass"}`)
+ if w.Code != 200 {
+ t.Fatal(w.Body.String())
+ }
+ oldCookie := w.Result().Cookies()[0]
+ before := s.basicAuth.snapshot()
+ if strings.Contains(w.Body.String(), "password_hash") || strings.Contains(store.raw, "test-pass") {
+ t.Fatal("password leaked")
+ }
+ if bcrypt.CompareHashAndPassword([]byte(before.PasswordHash), []byte("test-pass")) != nil {
+ t.Fatal("password not hashed")
+ }
+ var restarted basicAuthGate
+ if err := restarted.load(store); err != nil || restarted.snapshot() != before {
+ t.Fatalf("restart lost config: %v", err)
+ }
+ w = saveBasicAuthForTest(s, `{"enabled":false,"password":""}`)
+ if w.Code != 200 || w.Result().Cookies()[0].MaxAge != -1 {
+ t.Fatal("disable failed")
+ }
+ w = saveBasicAuthForTest(s, `{"enabled":true,"password":""}`)
+ if w.Code != 200 || s.basicAuth.snapshot().PasswordHash != before.PasswordHash {
+ t.Fatal("blank password must preserve hash")
+ }
+ r := httptest.NewRequest("GET", "/", nil)
+ r.AddCookie(oldCookie)
+ if s.validBasicAuthCookie(r, s.basicAuth.snapshot()) {
+ t.Fatal("old cookie survived disable/re-enable")
+ }
+ cookie := w.Result().Cookies()[0]
+ w = saveBasicAuthForTest(s, `{"username":"new-entry","password":"new-pass-123"}`)
+ r = httptest.NewRequest("GET", "/", nil)
+ r.AddCookie(cookie)
+ if w.Code != 200 || s.validBasicAuthCookie(r, s.basicAuth.snapshot()) {
+ t.Fatal("credential change did not revoke cookie")
+ }
+ before = s.basicAuth.snapshot()
+ store.err = errors.New("DB unavailable")
+ w = saveBasicAuthForTest(s, `{"enabled":false}`)
+ if w.Code != 503 || s.basicAuth.snapshot() != before {
+ t.Fatal("failed save changed live config")
+ }
+}
+
+func TestBasicAuthValidationAndLoadFailures(t *testing.T) {
+ s, _ := basicAuthTestServer(t)
+ for _, body := range []string{
+ `{"enabled":true}`, `{"enabled":true,"username":"entry"}`,
+ `{"username":"bad:name"}`, `{"username":"bad\nname"}`, `{"password":"short"}`,
+ `{"password":"` + strings.Repeat("a", 73) + `"}`, `not-json`,
+ } {
+ if w := saveBasicAuthForTest(s, body); w.Code != 400 {
+ t.Fatalf("body=%s status=%d", body, w.Code)
+ }
+ }
+ for _, store := range []*basicAuthMemoryStore{
+ {err: errors.New("unavailable")}, {raw: `not-json`}, {raw: `{"enabled":true}`},
+ {raw: `{"enabled":true,"username":"entry","revision":"v1","password_hash":"invalid"}`},
+ } {
+ var gate basicAuthGate
+ if err := gate.load(store); err == nil {
+ t.Fatal("invalid persisted config did not fail closed")
+ }
+ }
+}
+
+func TestBasicAuthExpiredCookieAndSecureFlag(t *testing.T) {
+ s, _ := basicAuthTestServer(t)
+ w := saveBasicAuthForTest(s, `{"enabled":true,"username":"entry","password":"test-pass"}`)
+ if w.Code != 200 {
+ t.Fatal(w.Body.String())
+ }
+ cfg := s.basicAuth.snapshot()
+ for _, expires := range []*jwt.NumericDate{nil, jwt.NewNumericDate(time.Now().Add(-time.Minute))} {
+ token, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
+ Subject: cfg.Revision, Audience: jwt.ClaimStrings{"artex-basic-auth"}, ExpiresAt: expires,
+ }).SignedString(s.basicAuthSigningKey())
+ r := httptest.NewRequest("GET", "/", nil)
+ r.AddCookie(&http.Cookie{Name: basicAuthCookie, Value: token})
+ if s.validBasicAuthCookie(r, cfg) {
+ t.Fatal("expired or unbounded cookie accepted")
+ }
+ }
+ r := httptest.NewRequest("GET", "https://example.test/login", nil)
+ w = httptest.NewRecorder()
+ if err := s.setBasicAuthCookie(w, r, cfg); err != nil {
+ t.Fatal(err)
+ }
+ if !w.Result().Cookies()[0].Secure {
+ t.Fatal("HTTPS cookie is not Secure")
+ }
+ var public map[string]any
+ w = httptest.NewRecorder()
+ s.getBasicAuthSettings(w, r)
+ if err := json.Unmarshal(w.Body.Bytes(), &public); err != nil {
+ t.Fatal(err)
+ }
+ if len(public) != 3 || public["password_set"] != true {
+ t.Fatalf("bad public settings: %v", public)
+ }
+}
+
+func TestBasicAuthCORS(t *testing.T) {
+ for _, tc := range []struct {
+ origin string
+ credentials bool
+ }{
+ {"http://127.0.0.1:3000", true}, {"https://evil.test", false}, {"http://127.0.0.1.evil.test:3000", false},
+ } {
+ r := httptest.NewRequest("OPTIONS", "http://127.0.0.1:8787/api/settings/basic-auth", nil)
+ r.Header.Set("Origin", tc.origin)
+ w := httptest.NewRecorder()
+ cors(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("preflight reached handler") })).ServeHTTP(w, r)
+ if w.Code != 204 || (w.Header().Get("Access-Control-Allow-Credentials") == "true") != tc.credentials {
+ t.Fatalf("unexpected CORS for %s: %v", tc.origin, w.Header())
+ }
+ }
+}
+
+func TestBasicAuthPostgresLoginFlow(t *testing.T) {
+ m, err := NewManager(t.TempDir(), "")
+ if err != nil {
+ t.Skipf("postgres unavailable: %v", err)
+ }
+ defer m.Close()
+ for _, key := range []string{basicAuthKey, authPassKey} {
+ old, exists, err := m.pg.GetSetting(key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func(key, old string, exists bool) {
+ if exists {
+ _ = m.pg.SetSetting(key, old)
+ } else {
+ _, _ = m.pg.Exec("DELETE FROM settings WHERE key=$1", key)
+ }
+ }(key, old, exists)
+ }
+ if err := m.pg.SetSetting(basicAuthKey, `{"enabled":false}`); err != nil {
+ t.Fatal(err)
+ }
+ hash, err := bcrypt.GenerateFromPassword([]byte("admin-password"), bcrypt.MinCost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := m.pg.SetSetting(authPassKey, string(hash)); err != nil {
+ t.Fatal(err)
+ }
+ s := &Server{m: m, jwtKey: []byte("test-only-signing-key-32-characters")}
+ if err := s.basicAuth.load(m.pg); err != nil {
+ t.Fatal(err)
+ }
+ adminJWT, err := signJWT(s.jwtKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ body := `{"enabled":true,"username":"entry","password":"entry-password"}`
+ r := httptest.NewRequest("PUT", "/api/settings/basic-auth", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ s.Handler().ServeHTTP(w, r)
+ if w.Code != 401 || s.basicAuth.snapshot().Enabled {
+ t.Fatal("unauthenticated settings write succeeded")
+ }
+ r = httptest.NewRequest("PUT", "/api/settings/basic-auth", strings.NewReader(body))
+ r.Header.Set("Authorization", "Bearer "+adminJWT)
+ w = httptest.NewRecorder()
+ s.Handler().ServeHTTP(w, r)
+ if w.Code != 200 {
+ t.Fatalf("save: %d %s", w.Code, w.Body.String())
+ }
+
+ restarted := &Server{m: m, jwtKey: s.jwtKey}
+ if err := restarted.basicAuth.load(m.pg); err != nil {
+ t.Fatal(err)
+ }
+ loginBody := `{"username":"ARTEX","password":"admin-password"}`
+ r = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(loginBody))
+ w = httptest.NewRecorder()
+ restarted.Handler().ServeHTTP(w, r)
+ if w.Code != 401 || w.Header().Get("WWW-Authenticate") == "" {
+ t.Fatal("login was not protected after restart")
+ }
+ r = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(loginBody))
+ r.SetBasicAuth("entry", "entry-password")
+ w = httptest.NewRecorder()
+ restarted.Handler().ServeHTTP(w, r)
+ var login struct {
+ Token string `json:"token"`
+ }
+ if err := json.Unmarshal(w.Body.Bytes(), &login); err != nil || w.Code != 200 || !verifyJWT(login.Token, s.jwtKey) {
+ t.Fatalf("Basic Auth + existing login failed: %d %s", w.Code, w.Body.String())
+ }
+ cookie := w.Result().Cookies()[0]
+ r = httptest.NewRequest("PUT", "/api/settings/basic-auth", strings.NewReader(`{"enabled":false}`))
+ r.AddCookie(cookie)
+ r.Header.Set("Authorization", "Bearer "+login.Token)
+ w = httptest.NewRecorder()
+ restarted.Handler().ServeHTTP(w, r)
+ if w.Code != 200 {
+ t.Fatalf("disable with gate cookie and JWT: %d %s", w.Code, w.Body.String())
+ }
+}
diff --git a/server/server.go b/server/server.go
index f79605d..c661be7 100644
--- a/server/server.go
+++ b/server/server.go
@@ -43,8 +43,9 @@ type Server struct {
engine *Engine
ctx context.Context
- skillDir string // root directory for skill subdirectories
- jwtKey []byte // HS256 signing key loaded from / generated into dataDir/jwt.key
+ skillDir string // root directory for skill subdirectories
+ jwtKey []byte // HS256 signing key loaded from / generated into dataDir/jwt.key
+ basicAuth basicAuthGate
// concMu serializes concurrency-cap decisions (admission + reconcile) so a
// scheduler tick and an HTTP settings change / task creation can't both count
@@ -152,6 +153,11 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
provByProfile: map[int64]*provEntry{}, llmHealth: newLLMHealthRegistry(m.pg),
taskAgents: map[string]*taskAgentBundle{}, archiveWake: make(chan struct{}, 1)}
s.initSideQuestions()
+ if m.pg != nil {
+ if err := s.basicAuth.load(m.pg); err != nil {
+ log.Fatalf("[auth] load HTTP Basic Auth: %v", err)
+ }
+ }
// 熔断阈值/冷却是失败路径上的热参数,启动时把全局重试策略推给 Registry 一次;
// 之后每次保存策略再推一次(saveLLMRetryPolicy)。
s.applyRetryPolicy()
@@ -656,6 +662,12 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/auth/init", s.authInit)
mux.HandleFunc("POST /api/auth/login", s.authLogin)
mux.HandleFunc("POST /api/auth/change-password", s.authChangePassword)
+ mux.HandleFunc("GET /api/basic-auth/check", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "no-store")
+ w.WriteHeader(http.StatusNoContent)
+ })
+ mux.HandleFunc("GET /api/settings/basic-auth", s.getBasicAuthSettings)
+ mux.HandleFunc("PUT /api/settings/basic-auth", s.saveBasicAuthSettings)
mux.HandleFunc("GET /api/health", s.health)
mux.HandleFunc("GET /api/stats", s.stats)
@@ -933,13 +945,13 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/intercept/judge/usage", s.interceptJudgeUsage) // 兜底审批累计 token 用量
// /api/* goes through CORS + JWT; everything else is served by the embedded
- // frontend (public — auth is enforced client-side and on the API). With the
+ // frontend (no JWT required; the optional Basic Auth gate wraps both). With the
// no-embed build the webui handler just 404s (run `next dev` separately).
api := cors(s.requireAuth(mux))
root := http.NewServeMux()
root.Handle("/api/", api)
root.Handle("/", s.webuiHandler())
- return root
+ return s.requireBasicAuth(root)
}
// --- handlers ---
@@ -3880,8 +3892,22 @@ func (s *Server) gc(w http.ResponseWriter, r *http.Request) {
func cors(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
+ // Next dev and the Go SSE endpoint use different ports on the same
+ // loopback host. Allow the gate cookie only for that local pairing.
+ origin := r.Header.Get("Origin")
+ u, err := url.Parse(origin)
+ host, _, hostErr := net.SplitHostPort(r.Host)
+ if hostErr != nil {
+ host = r.Host
+ }
+ if err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() == host &&
+ (host == "localhost" || host == "127.0.0.1" || host == "::1") {
+ w.Header().Set("Access-Control-Allow-Origin", origin)
+ w.Header().Set("Access-Control-Allow-Credentials", "true")
+ }
+ w.Header().Add("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
- w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
+ w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(204)
return
diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx
index 198ecf7..65da0c3 100644
--- a/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx
+++ b/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx
@@ -50,7 +50,7 @@ import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useSideQuestions } from "@/hooks/use-side-questions";
-import { api, sseUrl } from "@/lib/api";
+import { api, openSSE } from "@/lib/api";
import { shouldSubmitOnKey, useChatSendMode } from "@/lib/chat-send-mode";
import { MOCK } from "@/lib/mock/enabled";
import { isBtwCommand } from "@/lib/side-questions";
@@ -969,9 +969,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
};
});
// Open the single task SSE from the snapshot cursor.
- const es = new EventSource(
- sseUrl(`/api/exploration/activity/stream?task=${encodeURIComponent(taskId)}&since=${snapshotRef.current}`),
- );
+ const es = openSSE(`/api/exploration/activity/stream?task=${encodeURIComponent(taskId)}&since=${snapshotRef.current}`);
esRef.current = es;
es.onopen = () => setSseLive(true);
es.onerror = () => setSseLive(false); // EventSource auto-reconnects; DB compensates the gap
diff --git a/web/src/app/(main)/system/logs/page.tsx b/web/src/app/(main)/system/logs/page.tsx
index 9ba1fca..2cdf4f2 100644
--- a/web/src/app/(main)/system/logs/page.tsx
+++ b/web/src/app/(main)/system/logs/page.tsx
@@ -3,7 +3,7 @@
import * as React from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
-import { sseUrl } from "@/lib/api";
+import { openSSE } from "@/lib/api";
import { MOCK } from "@/lib/mock/enabled";
import type { LogLine } from "@/lib/types";
import { cn } from "@/lib/utils";
@@ -64,7 +64,7 @@ export default function LogsPage() {
setLines(MOCK_LOGS);
return;
}
- const es = new EventSource(sseUrl("/api/logs/stream?since=0"));
+ const es = openSSE("/api/logs/stream?since=0");
es.onmessage = (e) => {
if (pausedRef.current) return;
try {
diff --git a/web/src/app/(main)/system/settings/_components/basic-auth-card.tsx b/web/src/app/(main)/system/settings/_components/basic-auth-card.tsx
new file mode 100644
index 0000000..d649daf
--- /dev/null
+++ b/web/src/app/(main)/system/settings/_components/basic-auth-card.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+
+import { ShieldCheckIcon } from "lucide-react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
+import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
+import { Input } from "@/components/ui/input";
+import { Switch } from "@/components/ui/switch";
+import { api } from "@/lib/api";
+
+export function BasicAuthCard() {
+ const [enabled, setEnabled] = useState(false);
+ const [username, setUsername] = useState("");
+ const [password, setPassword] = useState("");
+ const [passwordSet, setPasswordSet] = useState(false);
+ const [loaded, setLoaded] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+
+ const load = useCallback(async () => {
+ setError("");
+ try {
+ const settings = await api.basicAuthSettings();
+ setEnabled(settings.enabled);
+ setUsername(settings.username);
+ setPasswordSet(settings.password_set);
+ setLoaded(true);
+ } catch (e) {
+ setError(`读取失败:${(e as Error).message}`);
+ }
+ }, []);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ const save = async (event: React.FormEvent) => {
+ event.preventDefault();
+ setSaving(true);
+ setError("");
+ try {
+ const settings = await api.setBasicAuthSettings({ enabled, username: username.trim(), password });
+ setEnabled(settings.enabled);
+ setUsername(settings.username);
+ setPasswordSet(settings.password_set);
+ setPassword("");
+ toast.success(settings.enabled ? "HTTP Basic Auth 已开启,当前浏览器保持访问" : "HTTP Basic Auth 已关闭");
+ } catch (e) {
+ setError(`保存失败:${(e as Error).message}`);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+