mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-11-02 10:53:49 +08:00
fix(auth): keep config persistence off the request lock
This commit is contained in:
1 parent
04c8c54ff8
commit
ead58f0983
3 files changed
+206
-12
No files matched your search
+10
-2
@@ -1,6 +1,9 @@
|
||||
package db
|
||||
|
||||
import "database/sql"
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// Settings is a tiny key-value store for global app config the UI toggles at
|
||||
// runtime (e.g. traffic_capture). Missing keys fall back to caller defaults.
|
||||
@@ -19,7 +22,12 @@ func (d *DB) GetSetting(key string) (value string, ok bool, err error) {
|
||||
|
||||
// SetSetting upserts a setting value.
|
||||
func (d *DB) SetSetting(key, value string) error {
|
||||
_, err := d.Exec(`
|
||||
return d.SetSettingContext(context.Background(), key, value)
|
||||
}
|
||||
|
||||
// SetSettingContext upserts a setting and respects cancellation or a deadline.
|
||||
func (d *DB) SetSettingContext(ctx context.Context, key, value string) error {
|
||||
_, err := d.ExecContext(ctx, `
|
||||
INSERT INTO settings(key, value) VALUES ($1, $2)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, key, value)
|
||||
return err
|
||||
|
||||
+23
-9
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
const basicAuthKey = "auth.http_basic"
|
||||
const basicAuthCookie = "artex_basic_auth"
|
||||
const basicAuthTTL = 12 * time.Hour
|
||||
const basicAuthSaveTimeout = 10 * time.Second
|
||||
|
||||
type basicAuthConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -29,18 +31,21 @@ type basicAuthConfig struct {
|
||||
|
||||
type basicAuthSettingsStore interface {
|
||||
GetSetting(string) (string, bool, error)
|
||||
SetSetting(string, string) error
|
||||
SetSettingContext(context.Context, 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
|
||||
saveMu sync.Mutex // serializes persistence without blocking request snapshots
|
||||
mu sync.RWMutex
|
||||
cfg basicAuthConfig
|
||||
store basicAuthSettingsStore
|
||||
}
|
||||
|
||||
func (g *basicAuthGate) load(store basicAuthSettingsStore) error {
|
||||
g.saveMu.Lock()
|
||||
defer g.saveMu.Unlock()
|
||||
raw, ok, err := store.GetSetting(basicAuthKey)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -169,13 +174,15 @@ func (s *Server) saveBasicAuthSettings(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
g := &s.basicAuth
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.store == nil {
|
||||
g.saveMu.Lock()
|
||||
defer g.saveMu.Unlock()
|
||||
g.mu.RLock()
|
||||
cfg, store := g.cfg, g.store
|
||||
g.mu.RUnlock()
|
||||
if store == nil {
|
||||
writeErr(w, 503, errDataSourceUnavailable)
|
||||
return
|
||||
}
|
||||
cfg := g.cfg
|
||||
if req.Enabled != nil {
|
||||
cfg.Enabled = *req.Enabled
|
||||
}
|
||||
@@ -205,11 +212,18 @@ func (s *Server) saveBasicAuthSettings(w http.ResponseWriter, r *http.Request) {
|
||||
// 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 {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), basicAuthSaveTimeout)
|
||||
defer cancel()
|
||||
if err != nil || store.SetSettingContext(ctx, basicAuthKey, string(data)) != nil {
|
||||
writeErr(w, 503, "Basic Auth 配置保存失败,请重试")
|
||||
return
|
||||
}
|
||||
// Hashing, persistence and response output never hold the request mutex.
|
||||
// Publish only committed settings; requests keep using the previous snapshot
|
||||
// until this short critical section completes.
|
||||
g.mu.Lock()
|
||||
g.cfg = cfg
|
||||
g.mu.Unlock()
|
||||
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 {
|
||||
|
||||
+173
-1
@@ -1,11 +1,13 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -21,7 +23,10 @@ type basicAuthMemoryStore struct {
|
||||
func (m *basicAuthMemoryStore) GetSetting(string) (string, bool, error) {
|
||||
return m.raw, m.raw != "", m.err
|
||||
}
|
||||
func (m *basicAuthMemoryStore) SetSetting(_, value string) error {
|
||||
func (m *basicAuthMemoryStore) SetSettingContext(ctx context.Context, _ string, value string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.err != nil {
|
||||
return m.err
|
||||
}
|
||||
@@ -45,6 +50,173 @@ func saveBasicAuthForTest(s *Server, body string) *httptest.ResponseRecorder {
|
||||
return w
|
||||
}
|
||||
|
||||
type blockedBasicAuthStore struct {
|
||||
basicAuthSettingsStore
|
||||
started chan basicAuthConfig
|
||||
release chan struct{}
|
||||
contexts chan context.Context
|
||||
}
|
||||
|
||||
func (b *blockedBasicAuthStore) SetSettingContext(ctx context.Context, key, value string) error {
|
||||
var cfg basicAuthConfig
|
||||
if err := json.Unmarshal([]byte(value), &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
b.started <- cfg
|
||||
if b.contexts != nil {
|
||||
b.contexts <- ctx
|
||||
}
|
||||
select {
|
||||
case <-b.release:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
return b.basicAuthSettingsStore.SetSettingContext(ctx, key, value)
|
||||
}
|
||||
|
||||
func TestBasicAuthBlockedSaveDoesNotBlockRequests(t *testing.T) {
|
||||
for _, enabled := range []bool{false, true} {
|
||||
name := "disabled"
|
||||
if enabled {
|
||||
name = "enabled"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
s, store := basicAuthTestServer(t)
|
||||
body := `{"enabled":false,"username":"entry","password":"original-pass"}`
|
||||
if enabled {
|
||||
body = `{"enabled":true,"username":"entry","password":"original-pass"}`
|
||||
}
|
||||
initial := saveBasicAuthForTest(s, body)
|
||||
if initial.Code != http.StatusOK {
|
||||
t.Fatal(initial.Body.String())
|
||||
}
|
||||
before := s.basicAuth.snapshot()
|
||||
blocked := &blockedBasicAuthStore{basicAuthSettingsStore: store, started: make(chan basicAuthConfig, 2), release: make(chan struct{})}
|
||||
s.basicAuth.mu.Lock()
|
||||
s.basicAuth.store = blocked
|
||||
s.basicAuth.mu.Unlock()
|
||||
var releaseOnce sync.Once
|
||||
release := func() { releaseOnce.Do(func() { close(blocked.release) }) }
|
||||
t.Cleanup(release)
|
||||
first := make(chan *httptest.ResponseRecorder, 1)
|
||||
go func() { first <- saveBasicAuthForTest(s, `{"username":"new-entry"}`) }()
|
||||
select {
|
||||
case <-blocked.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("save did not reach database")
|
||||
}
|
||||
// Exercise every kind of request sharing the gate while persistence waits.
|
||||
reads := make(chan error, 1)
|
||||
go func() {
|
||||
if s.basicAuth.snapshot() != before {
|
||||
reads <- errors.New("uncommitted settings became visible")
|
||||
return
|
||||
}
|
||||
h := s.requireBasicAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }))
|
||||
for _, path := range []string{"/", "/_next/static/app.js", "/api/tasks", "/api/health"} {
|
||||
r := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
if enabled && path != "/api/health" {
|
||||
r.AddCookie(initial.Result().Cookies()[0])
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusNoContent {
|
||||
reads <- errors.New("request rejected while old configuration is still active")
|
||||
return
|
||||
}
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/health", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
reads <- errors.New("health handler unavailable during save")
|
||||
return
|
||||
}
|
||||
reads <- nil
|
||||
}()
|
||||
select {
|
||||
case err := <-reads:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("database save blocked configuration reads and HTTP requests")
|
||||
}
|
||||
// A concurrent partial update must wait and preserve the first update.
|
||||
second := make(chan *httptest.ResponseRecorder, 1)
|
||||
go func() { second <- saveBasicAuthForTest(s, `{"enabled":true}`) }()
|
||||
select {
|
||||
case <-blocked.started:
|
||||
t.Fatal("concurrent saves reached persistence together")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
release()
|
||||
for _, done := range []chan *httptest.ResponseRecorder{first, second} {
|
||||
select {
|
||||
case response := <-done:
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatal(response.Body.String())
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("save did not finish after database release")
|
||||
}
|
||||
}
|
||||
cfg := s.basicAuth.snapshot()
|
||||
if cfg.Username != "new-entry" || !cfg.Enabled || cfg.PasswordHash != before.PasswordHash {
|
||||
t.Fatal("concurrent partial save lost a committed update")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthBlockedSaveRespectsRequestCancellation(t *testing.T) {
|
||||
s, store := basicAuthTestServer(t)
|
||||
before := s.basicAuth.snapshot()
|
||||
blocked := &blockedBasicAuthStore{basicAuthSettingsStore: store, started: make(chan basicAuthConfig, 1), release: make(chan struct{}), contexts: make(chan context.Context, 1)}
|
||||
s.basicAuth.mu.Lock()
|
||||
s.basicAuth.store = blocked
|
||||
s.basicAuth.mu.Unlock()
|
||||
t.Cleanup(func() { close(blocked.release) })
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
r := httptest.NewRequest(http.MethodPut, "/api/settings/basic-auth", strings.NewReader(`{"username":"new-entry"}`)).WithContext(ctx)
|
||||
done := make(chan *httptest.ResponseRecorder, 1)
|
||||
go func() {
|
||||
w := httptest.NewRecorder()
|
||||
s.saveBasicAuthSettings(w, r)
|
||||
done <- w
|
||||
}()
|
||||
select {
|
||||
case <-blocked.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("save did not reach database")
|
||||
}
|
||||
writeContext := <-blocked.contexts
|
||||
deadline, bounded := writeContext.Deadline()
|
||||
if !bounded || time.Until(deadline) > 10*time.Second {
|
||||
t.Fatal("database write has no bounded deadline")
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case w := <-done:
|
||||
if w.Code != http.StatusServiceUnavailable || len(w.Result().Cookies()) != 0 {
|
||||
t.Fatalf("canceled save must fail without issuing a cookie: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("database save ignored request cancellation")
|
||||
}
|
||||
if s.basicAuth.snapshot() != before || store.raw != "" {
|
||||
t.Fatal("canceled save changed the committed settings")
|
||||
}
|
||||
// A canceled save must also release the save mutex for a later update.
|
||||
s.basicAuth.mu.Lock()
|
||||
s.basicAuth.store = store
|
||||
s.basicAuth.mu.Unlock()
|
||||
w := saveBasicAuthForTest(s, `{"username":"later-entry"}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("later save failed: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthGate(t *testing.T) {
|
||||
s, _ := basicAuthTestServer(t)
|
||||
h := s.requireBasicAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) }))
|
||||
|
||||
Reference in new issue
Block a user