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 ( + + + + + HTTP Basic Auth + + + 开启后,访客需先通过浏览器原生账号密码验证,再进入 ARTEX 登录页。默认关闭,保存后立即生效。 + + +
+ + + + 启用访问验证 + + + + 验证用户名 + setUsername(e.target.value)} + placeholder="独立于 ARTEX 登录账号" + /> + 不能包含冒号,最长 128 字节。 + + + 验证密码 + setPassword(e.target.value)} + placeholder={passwordSet ? "已设置,留空保持原密码" : "至少 8 位,最多 72 字节"} + /> + 与 ARTEX 登录密码独立。公网访问请使用 HTTPS。 + + + 保存不会中断当前浏览器访问。可用无痕窗口检查验证弹窗;浏览器可能缓存凭据。关闭后保留账号密码,便于再次开启。 + + {error ? ( +

+ {error} +

+ ) : null} +
+
+ + + {!loaded && error ? ( + + ) : null} + +
+
+ ); +} diff --git a/web/src/app/(main)/system/settings/_components/update-card.tsx b/web/src/app/(main)/system/settings/_components/update-card.tsx index ff83c65..e4ef334 100644 --- a/web/src/app/(main)/system/settings/_components/update-card.tsx +++ b/web/src/app/(main)/system/settings/_components/update-card.tsx @@ -16,7 +16,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; -import { api, sseUrl } from "@/lib/api"; +import { api, openSSE } from "@/lib/api"; import type { UpdateCheck, UpdateProgress } from "@/lib/types"; /** 等待新版本上线的最长时间。一次升级要经过三次进程启动(暂存 → 换装 → 新版), @@ -100,7 +100,7 @@ export function UpdateCard() { // 订阅更新进度。SSE 不走 Next 的 /api 重写(那层会缓冲,事件推不出来)。 const openStream = React.useCallback( (fromVersion: string) => { - const es = new EventSource(sseUrl("/api/update/stream")); + const es = openSSE("/api/update/stream"); es.onmessage = (ev) => { let p: UpdateProgress; try { diff --git a/web/src/app/(main)/system/settings/page.tsx b/web/src/app/(main)/system/settings/page.tsx index 2ef42ba..2fae9d0 100644 --- a/web/src/app/(main)/system/settings/page.tsx +++ b/web/src/app/(main)/system/settings/page.tsx @@ -16,6 +16,7 @@ import { CHAT_SEND_MODE_OPTIONS, type ChatSendMode, setChatSendMode, useChatSend import type { Settings } from "@/lib/types"; import { UpdateCard } from "./_components/update-card"; +import { BasicAuthCard } from "./_components/basic-auth-card"; export default function SystemSettingsPage() { const [trafficCapture, setTrafficCapture] = React.useState(false); @@ -267,6 +268,7 @@ export default function SystemSettingsPage() { column-gap 只管列间距,行间距要由子元素自己给。 */}
+ diff --git a/web/src/hooks/use-side-questions.ts b/web/src/hooks/use-side-questions.ts index ef530d2..a02100c 100644 --- a/web/src/hooks/use-side-questions.ts +++ b/web/src/hooks/use-side-questions.ts @@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; -import { sseUrl } from "@/lib/api"; +import { openSSE } from "@/lib/api"; import { isBtwCommand, type SideExchange, type SideHistory, sideAPI } from "@/lib/side-questions"; // crypto.randomUUID 仅在安全上下文可用(https/localhost);经 IP+http 访问时降级。 @@ -128,7 +128,7 @@ export function useSideQuestions(parent: string | null) { useEffect(() => { if (!runningID) return; const version = epoch.current; - const stream = new EventSource(sseUrl(`/api/side-questions/${runningID}/events`)); + const stream = openSSE(`/api/side-questions/${runningID}/events`); stream.addEventListener("snapshot", (event) => { if (version !== epoch.current) return; try { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index c807c9b..90a00c2 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -197,6 +197,15 @@ export function sseUrl(path: string): string { return token ? `${base}${path}${sep}token=${encodeURIComponent(token)}` : `${base}${path}`; } +export function openSSE(path: string): EventSource { + const url = sseUrl(path); + const target = new URL(url, window.location.href); + // Same-origin streams already send cookies. Only opt into cross-port cookies + // for the local Next/Go dev pair; keep custom remote SSE endpoints unchanged. + const local = ["localhost", "127.0.0.1", "[::1]"].includes(target.hostname); + return new EventSource(url, { withCredentials: local && target.hostname === window.location.hostname }); +} + const get = (p: string) => http(p); const post = (p: string, body?: unknown) => http(p, { method: "POST", body: body ? JSON.stringify(body) : undefined }); @@ -812,6 +821,9 @@ export const api = { // ---- app settings (runtime toggles) ---- settings: () => get(`/settings`), + basicAuthSettings: () => get<{ enabled: boolean; username: string; password_set: boolean }>("/settings/basic-auth"), + setBasicAuthSettings: (settings: { enabled: boolean; username: string; password: string }) => + put<{ enabled: boolean; username: string; password_set: boolean }>("/settings/basic-auth", settings), setSettings: (patch: Partial) => put(`/settings`, patch), // Run a real "test" search with the given (or saved) config to verify it works. testWebSearch: (patch: { diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index 8b6229b..701596f 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -2220,6 +2220,8 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re if (path === "/traffic/exchange") return D.trafficDetail; if (path === "/settings" && m === "GET") return D.settings; if (path === "/settings" && m === "PUT") return { ...D.settings, ...b }; + if (path === "/settings/basic-auth" && m === "GET") return { enabled: false, username: "", password_set: false }; + if (path === "/settings/basic-auth" && m === "PUT") throw new Error("演示模式不支持修改 HTTP Basic Auth,请在实际部署中配置"); if (path === "/settings/web-search/test") return { ok: true, count: 5, backend: D.settings.web_search_backend }; if (path === "/settings/python/detect") return { python_interpreter: "/usr/bin/python3" }; if (path === "/chat") diff --git a/web/src/proxy.ts b/web/src/proxy.ts index 480ce62..67463a2 100644 --- a/web/src/proxy.ts +++ b/web/src/proxy.ts @@ -1,30 +1,77 @@ -import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; const AUTH_PAGES = ["/login", "/setup"]; -export function proxy(request: NextRequest) { +export async function proxy(request: NextRequest) { // Mock demo:无真实登录,放行所有页面(客户端 auth 守卫也会放行)。 if (process.env.NEXT_PUBLIC_MOCK === "1") return NextResponse.next(); + // Pages served by Next dev must pass the same gate as the embedded UI. + // Only send credentials to a loopback backend; never follow redirects. + // Remote dev backends remain usable when their optional gate is disabled. + let gate: Response; + try { + const backend = new URL(process.env.AUTOPENTEST_API ?? "http://localhost:8787"); + if (!["http:", "https:"].includes(backend.protocol) || backend.username || backend.password) { + return new NextResponse("认证后端地址配置无效", { status: 503 }); + } + const local = ["localhost", "127.0.0.1", "[::1]"].includes(backend.hostname); + gate = await fetch(new URL("/api/basic-auth/check", backend), { + headers: local + ? { + cookie: request.headers.get("cookie") ?? "", + authorization: request.headers.get("authorization") ?? "", + "x-forwarded-proto": request.nextUrl.protocol.replace(":", ""), + } + : {}, + credentials: "omit", + cache: "no-store", + redirect: "error", + signal: AbortSignal.timeout(5000), + }); + if (!local && gate.status === 401) { + return new NextResponse("远程后端已启用 HTTP Basic Auth,请通过内嵌前端访问该服务", { status: 503 }); + } + } catch { + return new NextResponse("认证服务暂时不可用,请稍后重试", { status: 503 }); + } + if (!gate.ok) { + const headers = new Headers({ "Cache-Control": "no-store", "Content-Type": "text/plain; charset=utf-8" }); + const challenge = gate.headers.get("www-authenticate"); + if (challenge) headers.set("WWW-Authenticate", challenge); + return new NextResponse(gate.status === 401 ? "请完成 HTTP Basic Auth 验证后刷新页面" : "认证服务暂时不可用", { + status: gate.status, + headers, + }); + } + const finish = (response: NextResponse) => { + const cookie = gate.headers.get("set-cookie"); + if (cookie) response.headers.append("Set-Cookie", cookie); + response.headers.set("Cache-Control", "no-store"); + return response; + }; + const { pathname } = request.nextUrl; const token = request.cookies.get("artex_token")?.value; const isAuthPage = AUTH_PAGES.some((p) => pathname === p || pathname.startsWith(`${p}/`)); // 未登录 → 跳转登录页 if (!token && !isAuthPage) { - return NextResponse.redirect(new URL("/login", request.url)); + return finish(NextResponse.redirect(new URL("/login", request.url))); } // 已登录时访问登录/初始化页 → 跳转主界面 if (token && isAuthPage) { - return NextResponse.redirect(new URL("/function/tasks", request.url)); + return finish(NextResponse.redirect(new URL("/function/tasks", request.url))); } - return NextResponse.next(); + return finish(NextResponse.next()); } export const config = { // 跳过 Next.js 内部路由、API 路由、favicon 及 public/ 下的静态文件(含图片、字体等) - matcher: ["/((?!_next/static|_next/image|favicon\\.ico|api/|.*\\.(?:png|jpg|jpeg|gif|webp|svg|ico|woff2?|ttf|otf)$).*)"], + matcher: [ + "/((?!_next/static|_next/image|favicon\\.ico|api/|.*\\.(?:png|jpg|jpeg|gif|webp|svg|ico|woff2?|ttf|otf)$).*)", + ], };