fix bug
This commit is contained in:
1 parent
b55ceb1fdd
commit
a951e4aad3
6 files changed
+279
-44
No files matched your search
+63
-5
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user