diff --git a/db/db.go b/db/db.go index a56f0db..90f9fcb 100644 --- a/db/db.go +++ b/db/db.go @@ -22,6 +22,11 @@ var schemaSQL string const schemaMigrationLockKey int64 = 7337741001 +// maxOpenConns 是连接池上限,见 Open 里的说明。取值远低于 PostgreSQL 默认的 +// max_connections=100,同时远高于应用自身的嵌套取连接深度(启动期的 schema +// advisory lock 会在持有一条连接的同时让 seedBuiltins 另取连接),不会自锁。 +const maxOpenConns = 32 + var schemaDeadlockRetryDelays = [...]time.Duration{ 100 * time.Millisecond, 250 * time.Millisecond, @@ -133,6 +138,16 @@ func Open(dsn string) (*DB, error) { if err != nil { return nil, err } + // database/sql 默认不限制连接数:池里没有空闲连接时会无条件新建,一路顶到 + // PostgreSQL 的 max_connections(默认 100)才被拒,于是高峰期的查询拿到的是 + // `FATAL: sorry, too many clients already` 这种**错误**。封顶之后超额查询改为 + // 排队等待空闲连接——同样的负载下变成变慢而不是报错,调用方不必再去区分 + // "读不到"和"没有"。maxOpenConns 要留出余量给 psql / reset-password.sh 以及 + // 可能并存的其他实例;若 max_connections 调低过,这里也要跟着往下调。 + sqlDB.SetMaxOpenConns(maxOpenConns) + sqlDB.SetMaxIdleConns(maxOpenConns) + sqlDB.SetConnMaxLifetime(30 * time.Minute) + sqlDB.SetConnMaxIdleTime(5 * time.Minute) if err := sqlDB.Ping(); err != nil { sqlDB.Close() return nil, fmt.Errorf("ping postgres (%s): %w", config.Redact(dsn), err) diff --git a/db/settings.go b/db/settings.go index edabe19..a394c9b 100644 --- a/db/settings.go +++ b/db/settings.go @@ -25,6 +25,24 @@ ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, key return err } +// InsertSettingIfAbsent 只在 key 尚未存在时写入,已存在则原样保留并返回 inserted=false。 +// 给 auth.password_hash 这类"只允许首次设置"的键用:判定落在数据库的主键约束上, +// 调用方先 GetSetting 再写的那种检查就只是快速失败路径——读出错或并发撞车时, +// 已有的值也不会被 ON CONFLICT DO UPDATE 顺手覆盖掉。 +func (d *DB) InsertSettingIfAbsent(key, value string) (inserted bool, err error) { + res, err := d.Exec(` +INSERT INTO settings(key, value) VALUES ($1, $2) +ON CONFLICT (key) DO NOTHING`, key, value) + if err != nil { + return false, err + } + n, err := res.RowsAffected() + if err != nil { + return false, err + } + return n > 0, nil +} + // GetBool returns the boolean setting, or def when unset/unparseable. func (d *DB) GetBool(key string, def bool) bool { v, ok, err := d.GetSetting(key) diff --git a/db/settings_insert_test.go b/db/settings_insert_test.go new file mode 100644 index 0000000..a3e62ef --- /dev/null +++ b/db/settings_insert_test.go @@ -0,0 +1,46 @@ +package db + +import ( + "fmt" + "testing" + "time" +) + +// InsertSettingIfAbsent 是 auth.password_hash 的兜底:调用方的 GetSetting 检查可能 +// 因为数据库报错而失效,也可能被并发请求插队(bcrypt 要跑几十毫秒),所以"仅首次 +// 可设"的保证必须落在主键约束上,而不是上层的 if 判断。 +func TestInsertSettingIfAbsentDoesNotOverwrite(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Skipf("postgres unavailable (%v)", err) + } + defer d.Close() + + // 用测试专属 key,绝不碰开发库里真实的 auth.password_hash。 + key := fmt.Sprintf("test.insert_if_absent.%d", time.Now().UnixNano()) + defer func() { _, _ = d.Exec(`DELETE FROM settings WHERE key=$1`, key) }() + + inserted, err := d.InsertSettingIfAbsent(key, "first") + if err != nil { + t.Fatal(err) + } + if !inserted { + t.Fatal("首次写入应返回 inserted=true") + } + + inserted, err = d.InsertSettingIfAbsent(key, "second") + if err != nil { + t.Fatal(err) + } + if inserted { + t.Fatal("键已存在时应返回 inserted=false") + } + + got, ok, err := d.GetSetting(key) + if err != nil || !ok { + t.Fatalf("GetSetting: ok=%v err=%v", ok, err) + } + if got != "first" { + t.Fatalf("值被覆盖成 %q,应保持 %q", got, "first") + } +} diff --git a/server/auth.go b/server/auth.go index 2fa398b..b0a6b09 100644 --- a/server/auth.go +++ b/server/auth.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strings" "time" + "unicode/utf8" "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/bcrypt" @@ -20,8 +21,30 @@ const ( authPassKey = "auth.password_hash" jwtTTL = 7 * 24 * time.Hour keyChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + + // 下限与 setup 页的前端校验一致——校验只放在前端等于没放,直接打 API 就能 + // 绕过。上限是 bcrypt 的硬限制:超过 72 字节 GenerateFromPassword 会返回 + // ErrPasswordTooLong,提前挡掉好过让用户收到一句含义不明的「密码加密失败」。 + minPasswordRunes = 8 + maxPasswordBytes = 72 ) +// errDataSourceUnavailable 是密码相关读操作失败时统一的回复。这些 handler 绝不能 +// 把"读不到"当成"没有设置":authInit 曾因此在数据库报错时放行,让未认证请求覆盖 +// 掉已有的管理员密码。 +const errDataSourceUnavailable = "数据源暂时不可用,请稍后重试" + +// validatePassword 返回空串表示通过,否则返回可直接展示给用户的中文原因。 +func validatePassword(pw string) string { + if utf8.RuneCountInString(pw) < minPasswordRunes { + return fmt.Sprintf("密码长度至少 %d 位", minPasswordRunes) + } + if len(pw) > maxPasswordBytes { + return fmt.Sprintf("密码长度不能超过 %d 字节", maxPasswordBytes) + } + return "" +} + // loadOrCreateJWTKey reads the 32-byte signing key from keyDir/jwt.key. keyDir is // the project base dir (next to the executable), NOT the browsable workspace root // (dataDir) — the signing key must never be listable/downloadable via the file @@ -115,12 +138,19 @@ func (s *Server) requireAuth(h http.Handler) http.Handler { } // GET /api/auth/status — reports whether the admin password has been initialised. +// 读失败必须回 503 而不是 initialized:false:前端在 initialized:false 时会把用户 +// 送到 /setup 去设置密码(login/page.tsx),把数据库故障包装成 200 等于把用户往 +// 覆盖已有密码的路上推。 func (s *Server) authStatus(w http.ResponseWriter, r *http.Request) { pg := s.pg(w) if pg == nil { return } - hash, _, _ := pg.GetSetting(authPassKey) + hash, _, err := pg.GetSetting(authPassKey) + if err != nil { + writeErr(w, 503, errDataSourceUnavailable) + return + } writeJSON(w, 200, map[string]any{"initialized": hash != ""}) } @@ -130,7 +160,11 @@ func (s *Server) authInit(w http.ResponseWriter, r *http.Request) { if pg == nil { return } - existing, _, _ := pg.GetSetting(authPassKey) + existing, _, err := pg.GetSetting(authPassKey) + if err != nil { + writeErr(w, 503, errDataSourceUnavailable) + return + } if existing != "" { writeErr(w, 403, "密码已设置") return @@ -142,15 +176,27 @@ func (s *Server) authInit(w http.ResponseWriter, r *http.Request) { writeErr(w, 400, "密码不能为空") return } + 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 } - if err := pg.SetSetting(authPassKey, string(hash)); err != nil { + // 用 INSERT ... ON CONFLICT DO NOTHING 而不是 upsert:上面那次 GetSetting 只是 + // 快速失败路径,真正"仅首次可设"的保证落在主键约束上。bcrypt 要跑几十毫秒, + // 这期间别的请求完全可能先把密码设好,而读检查本身也可能因故障而失效。 + inserted, err := pg.InsertSettingIfAbsent(authPassKey, string(hash)) + if err != nil { writeErr(w, 500, "保存失败: "+err.Error()) return } + if !inserted { + writeErr(w, 403, "密码已设置") + return + } tok, err := signJWT(s.jwtKey) if err != nil { writeErr(w, 500, "token 生成失败") @@ -183,7 +229,15 @@ func (s *Server) authChangePassword(w http.ResponseWriter, r *http.Request) { writeErr(w, 400, "新密码不能为空") return } - hash, ok, _ := pg.GetSetting(authPassKey) + if msg := validatePassword(req.NewPassword); msg != "" { + writeErr(w, 400, msg) + return + } + hash, ok, err := pg.GetSetting(authPassKey) + if err != nil { + writeErr(w, 503, errDataSourceUnavailable) + return + } if !ok || hash == "" { writeErr(w, 403, "密码未初始化,请先设置密码") return @@ -222,7 +276,11 @@ func (s *Server) authLogin(w http.ResponseWriter, r *http.Request) { writeErr(w, 401, "用户名或密码错误") return } - hash, ok, _ := pg.GetSetting(authPassKey) + hash, ok, err := pg.GetSetting(authPassKey) + if err != nil { + writeErr(w, 503, errDataSourceUnavailable) + return + } if !ok || hash == "" { writeErr(w, 403, "密码未初始化,请先设置密码") return diff --git a/server/auth_test.go b/server/auth_test.go new file mode 100644 index 0000000..14af881 --- /dev/null +++ b/server/auth_test.go @@ -0,0 +1,84 @@ +package server + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "testing" +) + +// GetSetting 对"键不存在"和"读取出错"的返回值只差一个 error:两种情况 value 都是 +// 空串。密码相关的 handler 一旦把 error 当成"还没设置密码",就会在数据库抖动期间 +// 敞开初始化入口——authInit 会放行一个未认证请求去覆盖已有的管理员密码, +// authStatus 则会把前端直接送到 /setup 去照着做这件事。 +// +// 这两个测试把 handler 的连接池关掉来制造读取失败,断言两处都 fail closed。 +func TestAuthStatusFailsClosedWhenDataSourceUnavailable(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v)", err) + } + defer m.Close() + // 关掉池子,让后续 GetSetting 返回 error 而不是 sql.ErrNoRows。 + if err := m.pg.Close(); err != nil { + t.Fatal(err) + } + + s := &Server{m: m} + w := httptest.NewRecorder() + s.authStatus(w, httptest.NewRequest("GET", "/api/auth/status", nil)) + + if w.Code != 503 { + t.Fatalf("status=%d want 503 (读失败被当成未初始化会把用户送去 /setup 覆盖密码); body=%s", w.Code, w.Body.String()) + } + var payload struct { + Initialized *bool `json:"initialized"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err == nil && payload.Initialized != nil { + t.Fatalf("读失败时不应回答 initialized,得到 %v", *payload.Initialized) + } +} + +func TestAuthInitFailsClosedWhenDataSourceUnavailable(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v)", err) + } + defer m.Close() + if err := m.pg.Close(); err != nil { + t.Fatal(err) + } + + s := &Server{m: m} + w := httptest.NewRecorder() + body := strings.NewReader(`{"password":"correct horse battery"}`) + s.authInit(w, httptest.NewRequest("POST", "/api/auth/init", body)) + + if w.Code != 503 { + t.Fatalf("status=%d want 503 (读失败时放行会让未认证请求覆盖已有密码); body=%s", w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), "token") { + t.Fatalf("读失败时不应签发 token: %s", w.Body.String()) + } +} + +func TestValidatePassword(t *testing.T) { + for _, tc := range []struct { + name, pw string + wantErr bool + }{ + {"空", "", true}, + {"七位", "1234567", true}, + {"八位", "12345678", false}, + {"八个汉字按字符数而非字节数计", "密码密码密码密码", false}, + {"三个汉字够 9 字节但只有 3 个字符", "密码强", true}, + {"72 字节", strings.Repeat("a", 72), false}, + {"73 字节超出 bcrypt 上限", strings.Repeat("a", 73), true}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := validatePassword(tc.pw); (got != "") != tc.wantErr { + t.Fatalf("validatePassword(%q)=%q, wantErr=%v", tc.pw, got, tc.wantErr) + } + }) + } +} diff --git a/web/src/app/(auth)/setup/page.tsx b/web/src/app/(auth)/setup/page.tsx index d024cd0..b09cf02 100644 --- a/web/src/app/(auth)/setup/page.tsx +++ b/web/src/app/(auth)/setup/page.tsx @@ -17,13 +17,20 @@ export default function SetupPage() { const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const [checking, setChecking] = useState(true); + // 查不到初始化状态时不能默认当成"未初始化"——那样会把初始化表单摆给一个 + // 其实已经设过密码的实例,用户照着填就会覆盖掉原密码。此时关闭入口。 + const [unavailable, setUnavailable] = useState(false); useEffect(() => { - api.authStatus() + api + .authStatus() .then(({ initialized }) => { if (initialized) router.replace("/login"); }) - .catch(() => setError("无法连接到后端服务")) + .catch((err) => { + setError(err instanceof Error ? err.message : "无法连接到后端服务"); + setUnavailable(true); + }) .finally(() => setChecking(false)); }, [router]); @@ -61,13 +68,7 @@ export default function SetupPage() {
{/* eslint-disable-next-line @next/next/no-img-element */} - ARTEX + ARTEX
@@ -75,38 +76,51 @@ export default function SetupPage() {
-

初始化密码

-

首次使用 ARTEX,请为账户设置一个登录密码(至少 8 位)

+

{unavailable ? "无法确认初始化状态" : "初始化密码"}

+

+ {unavailable + ? "后端或数据库暂时不可用。为避免覆盖实例上已有的密码,初始化入口已临时关闭——请恢复服务后重试。" + : "首次使用 ARTEX,请为账户设置一个登录密码(至少 8 位)"} +

-
-
- - setPassword(e.target.value)} - placeholder="至少 8 位" - autoFocus - autoComplete="new-password" - /> + {unavailable ? ( +
+ {error &&

{error}

} +
-
- - setConfirm(e.target.value)} - placeholder="再次输入密码" - autoComplete="new-password" - /> -
- {error &&

{error}

} - - + ) : ( +
+
+ + setPassword(e.target.value)} + placeholder="至少 8 位" + autoFocus + autoComplete="new-password" + /> +
+
+ + setConfirm(e.target.value)} + placeholder="再次输入密码" + autoComplete="new-password" + /> +
+ {error &&

{error}

} + +
+ )}