diff --git a/README.md b/README.md index adecf42..05e3fc5 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ install -d -m 750 -o 65532 -g 65532 data docker compose --env-file /docker/main/.env up -d --build --wait ``` -持久化数据位于 `deploy/data/`,与 `.dev-data/` 独立。首次初始化的启动码位于 `deploy/data/setup-code.txt`。 +持久化数据位于 `deploy/data/`,与 `.dev-data/` 独立。首次打开网页时直接设置登录密码。 宿主机 Nginx 配置为 `deploy/nginx/raw.134333.xyz.conf`,复用现有通配证书及 WebSocket 升级映射;安装到 `/etc/nginx/conf.d/134333.xyz/` 后,先执行 `nginx -t` 再重载。 域名 `raw.134333.xyz` 在 DNSPod 设置 CNAME 指向 `cdn.134333.xyz`(与 AlphaPulse 一致);还需在 Cloudflare for SaaS 的 `unino.top` 区域添加该自定义域名,并等待域名与托管证书均为 `active`。仅设置 CNAME 或旧区域中的 A 记录不足以完成 CDN 接入。访问地址为 `https://raw.134333.xyz`。修改端口时需同步端口登记和 Nginx 上游。 @@ -29,7 +29,7 @@ docker run -d --name rawmark \ rawmark ``` -- 首次打开需要输入启动码,位于数据目录的 `setup-code.txt`(如 `~/rawmark-data/setup-code.txt`),设置密码后自动删除。 +- 首次打开直接设置登录密码,之后凭密码登录;请在将新实例开放给他人访问前完成初始化。 - 笔记以普通 `.md` 文件保存在 `数据目录/vault/`,图片按 Typora 习惯存放在同级的 `笔记名.assets/`,可直接用 Typora 等编辑器打开;外部修改会实时同步到网页。 - `--user` 让容器以宿主机用户身份读写数据目录;不指定时容器以 UID 65532 运行,需要该用户对目录有写权限。 - 请通过 HTTPS 反向代理(Caddy、Nginx 等)访问,并转发 WebSocket(`/api/v1/events`)。 @@ -52,7 +52,7 @@ scripts/dev.sh # 后端 go run -tags dev + 前端 Vite 热更新,Ctrl scripts/dev.sh --host # 前端监听所有网卡,手机访问 http://<电脑 IP>:5173 ``` -浏览器打开 http://localhost:5173 ,启动码在 `.dev-data/setup-code.txt`。端口与数据目录可用 `API_PORT`、`WEB_PORT`、`RAWMARK_DATA` 覆盖,详见 `scripts/dev.sh --help`。 +浏览器打开 http://localhost:5173 ,首次使用直接设置登录密码。端口与数据目录可用 `API_PORT`、`WEB_PORT`、`RAWMARK_DATA` 覆盖,详见 `scripts/dev.sh --help`。 检查: diff --git a/cmd/rawmark/main.go b/cmd/rawmark/main.go index 9b34e7b..db0a404 100644 --- a/cmd/rawmark/main.go +++ b/cmd/rawmark/main.go @@ -62,21 +62,7 @@ func run(addr string) error { return err } defer st.Close() - am, err := auth.New(st) - if err != nil { - return err - } - - // 启动码写入数据目录下的文件而非日志:只有能访问服务器的人才能完成首次初始化 - codeFile := filepath.Join(dataDir, "setup-code.txt") - if code := am.SetupCode(); code != "" { - if err := os.WriteFile(codeFile, []byte(code+"\n"), 0o600); err != nil { - return fmt.Errorf("写入启动码失败: %w", err) - } - slog.Warn("尚未初始化:请在浏览器中打开 RawMark,输入启动码完成设置", "启动码文件", codeFile) - } else { - os.Remove(codeFile) - } + am := auth.New(st) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() @@ -97,7 +83,6 @@ func run(addr string) error { srv := api.New(v, am, st, hub, idx) srv.ThemesDir = filepath.Join(dataDir, "themes") srv.PluginsDir = filepath.Join(dataDir, "plugins") - srv.OnSetup = func() { os.Remove(codeFile) } go srv.FollowMoves(ctx) httpServer := &http.Server{ Addr: addr, diff --git a/internal/api/api.go b/internal/api/api.go index e8155ee..7735a41 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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) } } diff --git a/internal/api/api_test.go b/internal/api/api_test.go index a664f08..cf0b0ff 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -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) + } +} diff --git a/internal/api/openapi.json b/internal/api/openapi.json index 2b65039..fd97845 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -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": { diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 01d63d6..aabc408 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -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) diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 53141d5..f408714 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -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) diff --git a/scripts/dev.sh b/scripts/dev.sh index b5aae39..c63cabe 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -82,10 +82,6 @@ for _ in $(seq 1 120); do done curl -fs "http://127.0.0.1:$API_PORT/api/v1/health" >/dev/null 2>&1 || fail "后端 60 秒内未就绪,请查看上方 [server] 日志" -if [[ -f "$RAWMARK_DATA/setup-code.txt" ]]; then - echo "==> 首次使用:启动码在 $RAWMARK_DATA/setup-code.txt" -fi - echo "==> 启动前端 http://localhost:$WEB_PORT" (cd web && exec npx vite "${VITE_ARGS[@]}") 2>&1 | prefix web & diff --git a/web/src/app/AuthView.vue b/web/src/app/AuthView.vue index fb255c7..d7f7ae9 100644 --- a/web/src/app/AuthView.vue +++ b/web/src/app/AuthView.vue @@ -5,7 +5,6 @@ import { api, ApiError } from './api' const props = defineProps<{ mode: 'setup' | 'login' }>() const emit = defineEmits<{ done: [] }>() -const code = ref('') const password = ref('') const confirm = ref('') const error = ref('') @@ -20,7 +19,7 @@ async function submit() { } busy.value = true try { - if (isSetup.value) await api.setup(code.value, password.value) + if (isSetup.value) await api.setup(password.value) else await api.login(password.value) emit('done') } catch (err) { @@ -36,12 +35,8 @@ async function submit() {