feat(auth): add optional HTTP Basic Auth access gate

This commit is contained in:
ji ruo committed 2026-10-09 10:12:27 +08:00
1 parent 979e1fe4eb
commit 04c8c54ff8
14 files changed
+809 -23

No files matched your search

+2 -2
View File
@@ -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
}
+220
View File
@@ -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))
}
+328
View File
@@ -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())
}
}
+31 -5
View File
@@ -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