mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
feat(auth): add optional HTTP Basic Auth access gate
This commit is contained in:
1 parent
979e1fe4eb
commit
04c8c54ff8
14 files changed
+809
-23
No files matched your search
@@ -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 留在内网即可。
|
||||
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ShieldCheckIcon className="size-4" />
|
||||
HTTP Basic Auth
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
开启后,访客需先通过浏览器原生账号密码验证,再进入 ARTEX 登录页。默认关闭,保存后立即生效。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={save}>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<Field orientation="horizontal">
|
||||
<FieldLabel htmlFor="basic-auth-enabled">启用访问验证</FieldLabel>
|
||||
<Switch
|
||||
id="basic-auth-enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
disabled={!loaded || saving}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="basic-auth-username">验证用户名</FieldLabel>
|
||||
<Input
|
||||
id="basic-auth-username"
|
||||
autoComplete="off"
|
||||
value={username}
|
||||
required={enabled}
|
||||
disabled={!loaded || saving}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="独立于 ARTEX 登录账号"
|
||||
/>
|
||||
<FieldDescription>不能包含冒号,最长 128 字节。</FieldDescription>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="basic-auth-password">验证密码</FieldLabel>
|
||||
<Input
|
||||
id="basic-auth-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
required={enabled && !passwordSet}
|
||||
disabled={!loaded || saving}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={passwordSet ? "已设置,留空保持原密码" : "至少 8 位,最多 72 字节"}
|
||||
/>
|
||||
<FieldDescription>与 ARTEX 登录密码独立。公网访问请使用 HTTPS。</FieldDescription>
|
||||
</Field>
|
||||
<FieldDescription>
|
||||
保存不会中断当前浏览器访问。可用无痕窗口检查验证弹窗;浏览器可能缓存凭据。关闭后保留账号密码,便于再次开启。
|
||||
</FieldDescription>
|
||||
{error ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
<CardFooter className="mt-4 gap-2">
|
||||
<Button type="submit" disabled={!loaded || saving}>
|
||||
{saving ? "保存中…" : "保存访问验证"}
|
||||
</Button>
|
||||
{!loaded && error ? (
|
||||
<Button type="button" variant="outline" onClick={load}>
|
||||
重新加载
|
||||
</Button>
|
||||
) : null}
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 只管列间距,行间距要由子元素自己给。 */}
|
||||
<div className="columns-1 gap-4 md:gap-6 lg:columns-2">
|
||||
<UpdateCard />
|
||||
<BasicAuthCard />
|
||||
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = <T>(p: string) => http<T>(p);
|
||||
const post = <T>(p: string, body?: unknown) =>
|
||||
http<T>(p, { method: "POST", body: body ? JSON.stringify(body) : undefined });
|
||||
@@ -812,6 +821,9 @@ export const api = {
|
||||
|
||||
// ---- app settings (runtime toggles) ----
|
||||
settings: () => get<Settings>(`/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<Settings>) => put<Settings>(`/settings`, patch),
|
||||
// Run a real "test" search with the given (or saved) config to verify it works.
|
||||
testWebSearch: (patch: {
|
||||
|
||||
@@ -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")
|
||||
|
||||
+53
-6
@@ -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)$).*)",
|
||||
],
|
||||
};
|
||||
Reference in new issue
Block a user