Remove setup code from first-run initialization
This commit is contained in:
1 parent
8b54258df5
commit
8b54ccba18
10 files changed
+125
-88
No files matched your search
+3
-8
@@ -38,8 +38,6 @@ type Server struct {
|
||||
index *search.Index
|
||||
// 串行化设置的"读取-合并-写入"
|
||||
settingsMu sync.Mutex
|
||||
// OnSetup 在首次初始化完成后调用(用于删除启动码文件)
|
||||
OnSetup func()
|
||||
// ThemesDir 指向数据目录中的主题目录;为空时只有内置主题。
|
||||
ThemesDir string
|
||||
PluginsDir string
|
||||
@@ -228,21 +226,18 @@ func (s *Server) authState(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) setup(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct{ Code, Password string }
|
||||
var body struct{ Password string }
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
switch err := s.auth.Setup(body.Code, body.Password); {
|
||||
case errors.Is(err, auth.ErrBadSetupCode), errors.Is(err, auth.ErrWeakPassword):
|
||||
switch err := s.auth.Setup(body.Password); {
|
||||
case errors.Is(err, auth.ErrWeakPassword):
|
||||
writeError(w, 400, err.Error())
|
||||
case errors.Is(err, auth.ErrAlreadySetUp):
|
||||
writeError(w, 409, err.Error())
|
||||
case err != nil:
|
||||
internalError(w, r, err)
|
||||
default:
|
||||
if s.OnSetup != nil {
|
||||
s.OnSetup()
|
||||
}
|
||||
s.startSession(w, r, body.Password)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
@@ -148,11 +149,8 @@ func newFixture(t *testing.T) *fixture {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
am, err := auth.New(st)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := am.Setup(am.SetupCode(), "password123"); err != nil {
|
||||
am := auth.New(st)
|
||||
if err := am.Setup("password123"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idx, err := search.Open(filepath.Join(dir, "index.db"), v)
|
||||
@@ -645,3 +643,97 @@ func TestChangePassword(t *testing.T) {
|
||||
t.Errorf("新密码登录,得到 %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupWithoutCode(t *testing.T) {
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "setup.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
server := New(nil, auth.New(st), st, nil, nil)
|
||||
srv := httptest.NewServer(server.Handler(fstest.MapFS{}))
|
||||
t.Cleanup(srv.Close)
|
||||
f := &fixture{t: t, url: srv.URL}
|
||||
owner := newClient()
|
||||
before := decode[struct{ SetUp, Authenticated bool }](t, f.do(owner, "GET", "/api/v1/auth/state", "", nil))
|
||||
if before.SetUp || before.Authenticated {
|
||||
t.Fatal("新实例不应已初始化或登录")
|
||||
}
|
||||
if status := f.status(owner, "POST", "/api/v1/auth/setup", `{"password":"short"}`, nil); status != 400 {
|
||||
t.Fatalf("弱密码应被拒绝: %d", status)
|
||||
}
|
||||
if status := f.status(owner, "POST", "/api/v1/auth/setup", `{"password":"password123"}`, nil); status != 200 {
|
||||
t.Fatalf("无需启动码即可初始化: %d", status)
|
||||
}
|
||||
after := decode[struct{ SetUp, Authenticated bool }](t, f.do(owner, "GET", "/api/v1/auth/state", "", nil))
|
||||
if !after.SetUp || !after.Authenticated {
|
||||
t.Fatal("初始化后应自动登录")
|
||||
}
|
||||
if status := f.status(newClient(), "POST", "/api/v1/auth/setup", `{"password":"replacement"}`, nil); status != 409 {
|
||||
t.Fatalf("重复初始化必须拒绝: %d", status)
|
||||
}
|
||||
// 新建 Manager 模拟重启,不能重新开放密码设置。
|
||||
reopened := auth.New(st)
|
||||
if err := reopened.Setup("replacement"); !errors.Is(err, auth.ErrAlreadySetUp) {
|
||||
t.Fatalf("重启后不能覆盖密码: %v", err)
|
||||
}
|
||||
if _, err := reopened.Login("test", "password123"); err != nil {
|
||||
t.Fatal("原密码应仍然有效", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentSetupCannotOverwritePassword(t *testing.T) {
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "setup.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
manager := auth.New(st)
|
||||
server := New(nil, manager, st, nil, nil)
|
||||
srv := httptest.NewServer(server.Handler(fstest.MapFS{}))
|
||||
t.Cleanup(srv.Close)
|
||||
passwords := []string{"password-one", "password-two"}
|
||||
type outcome struct {
|
||||
index, status int
|
||||
err error
|
||||
}
|
||||
results := make(chan outcome, 2)
|
||||
for index, password := range passwords {
|
||||
go func() {
|
||||
res, err := newClient().Post(srv.URL+"/api/v1/auth/setup", "application/json", strings.NewReader(`{"password":"`+password+`"}`))
|
||||
if err != nil {
|
||||
results <- outcome{index: index, err: err}
|
||||
return
|
||||
}
|
||||
res.Body.Close()
|
||||
results <- outcome{index: index, status: res.StatusCode}
|
||||
}()
|
||||
}
|
||||
winner, conflicts := -1, 0
|
||||
for range passwords {
|
||||
result := <-results
|
||||
if result.err != nil {
|
||||
t.Fatal(result.err)
|
||||
}
|
||||
switch result.status {
|
||||
case 200:
|
||||
if winner >= 0 {
|
||||
t.Fatal("初始化成功不止一次")
|
||||
}
|
||||
winner = result.index
|
||||
case 409:
|
||||
conflicts++
|
||||
default:
|
||||
t.Fatalf("初始化状态不符: %d", result.status)
|
||||
}
|
||||
}
|
||||
if winner < 0 || conflicts != 1 {
|
||||
t.Fatal("必须只有一个初始化请求成功")
|
||||
}
|
||||
if _, err := manager.Login("test", passwords[winner]); err != nil {
|
||||
t.Fatal("成功请求的密码未保存", err)
|
||||
}
|
||||
if _, err := manager.Login("test", passwords[1-winner]); !errors.Is(err, auth.ErrBadCredentials) {
|
||||
t.Fatal("失败请求不应修改密码", err)
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,7 @@
|
||||
},
|
||||
"/auth/setup": {
|
||||
"post": {
|
||||
"summary": "首次设置登录密码(需服务器数据目录中的启动码)",
|
||||
"summary": "首次设置登录密码",
|
||||
"tags": [
|
||||
"登录"
|
||||
],
|
||||
@@ -112,15 +112,11 @@
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code",
|
||||
"password"
|
||||
]
|
||||
}
|
||||
@@ -139,7 +135,7 @@
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "启动码错误或密码过短",
|
||||
"description": "密码过短",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -158,7 +154,8 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "仅未初始化实例可设置密码;首次成功后自动登录,已初始化时返回 409。"
|
||||
}
|
||||
},
|
||||
"/auth/login": {
|
||||
|
||||
+12
-32
@@ -40,7 +40,6 @@ const (
|
||||
|
||||
var (
|
||||
ErrAlreadySetUp = errors.New("已完成初始化")
|
||||
ErrBadSetupCode = errors.New("启动码错误")
|
||||
ErrWeakPassword = fmt.Errorf("密码至少 %d 位", MinPasswordLen)
|
||||
ErrBadCredentials = errors.New("密码错误")
|
||||
ErrRateLimited = errors.New("尝试次数过多,请稍后再试")
|
||||
@@ -48,33 +47,15 @@ var (
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
store *store.Store
|
||||
// 首次初始化需要的一次性启动码,仅打印在服务日志中,防止部署后被他人抢先设置密码
|
||||
setupCode string
|
||||
|
||||
store *store.Store
|
||||
mu sync.Mutex
|
||||
failures map[string][]time.Time
|
||||
blocked map[string]time.Time // 来源 -> 解封时间
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func New(s *store.Store) (*Manager, error) {
|
||||
m := &Manager{store: s, failures: map[string][]time.Time{}, blocked: map[string]time.Time{}, now: time.Now}
|
||||
ok, err := m.IsSetUp()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
m.setupCode = randomCode()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// SetupCode 返回启动码;已初始化时为空
|
||||
func (m *Manager) SetupCode() string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.setupCode
|
||||
func New(s *store.Store) *Manager {
|
||||
return &Manager{store: s, failures: map[string][]time.Time{}, blocked: map[string]time.Time{}, now: time.Now}
|
||||
}
|
||||
|
||||
func (m *Manager) IsSetUp() (bool, error) {
|
||||
@@ -82,14 +63,16 @@ func (m *Manager) IsSetUp() (bool, error) {
|
||||
return ok, err
|
||||
}
|
||||
|
||||
func (m *Manager) Setup(code, password string) error {
|
||||
expected := m.SetupCode()
|
||||
if expected == "" {
|
||||
func (m *Manager) Setup(password string) error {
|
||||
// 已初始化时提前拒绝,避免公开接口重复计算密码哈希;并发首次写入仍由数据库保证原子性。
|
||||
setUp, err := m.IsSetUp()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if setUp {
|
||||
return ErrAlreadySetUp
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(strings.ToUpper(strings.TrimSpace(code))), []byte(expected)) != 1 {
|
||||
return ErrBadSetupCode
|
||||
}
|
||||
|
||||
if len([]rune(password)) < MinPasswordLen {
|
||||
return ErrWeakPassword
|
||||
}
|
||||
@@ -100,9 +83,6 @@ func (m *Manager) Setup(code, password string) error {
|
||||
if !ok {
|
||||
return ErrAlreadySetUp
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.setupCode = ""
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -348,7 +328,7 @@ func hashToken(token string) string {
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// randomCode 生成形如 ABCD-EFGH 的启动码,去掉易混淆字符
|
||||
// randomCode 生成形如 ABCD-EFGH 的 API 令牌标识,去掉易混淆字符
|
||||
func randomCode() string {
|
||||
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
b := make([]byte, 8)
|
||||
|
||||
@@ -16,11 +16,8 @@ func newManager(t *testing.T) (*Manager, *time.Time) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
m, err := New(st)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.Setup(m.SetupCode(), "password123"); err != nil {
|
||||
m := New(st)
|
||||
if err := m.Setup("password123"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
Reference in new issue
Block a user