From 4bb2ff41458d6930afe0cd9b55b1e2201bf67737 Mon Sep 17 00:00:00 2001 From: Sakurasan <26715255+Sakurasan@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:35:37 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=9A=E7=94=A8=E6=88=B7=E4=B8=8E=E8=A7=92?= =?UTF-8?q?=E8=89=B2=EF=BC=9Aowner=20/=20admin=20/=20reader=20=E4=B8=89?= =?UTF-8?q?=E7=BA=A7=EF=BC=8C=E7=94=A8=E6=88=B7=E7=AE=A1=E7=90=86=E9=A1=B5?= =?UTF-8?q?=20+=20=E5=AF=86=E7=A0=81=E4=B8=8A=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - users 表加 password_hash 列;后台账号(owner+admin)密码 bcrypt 存行内, 首次登录把 env / settings 引导凭据自迁移成行哈希 - 会话 token 从用户名改为携带用户 ID,角色与停用状态每请求查库, 改角色 / 停用账号即时生效(存量会话立即 401) - 登录:先查 users 表,再走 settings 哈希 / env 引导链; admin/admin 开发模式在任何账号设过密码后失效 - 权限:系统设置、用户管理仅 owner;内容管理 admin+owner; admin 后台新增 用户 页(创建 / 重置密码 / 停用 / 删除), 设置页「登录与存储」tab 对管理员隐藏 - 账户页加修改密码表单(旧密码校验,OAuth/Passkey 首设免旧密码); 评论区管理员身份跟随各自账号,不再统一挂站主名下 - 修复:providers 为 nil 时账户页白屏(Go nil slice 序列化成 null) --- README.md | 10 +- backend/internal/admin/account.go | 29 +- backend/internal/admin/account_test.go | 38 +-- backend/internal/admin/actor.go | 61 ++++ backend/internal/admin/api.go | 344 ++++++++++++++++++----- backend/internal/admin/api_test.go | 159 ++++++++++- backend/internal/admin/file_refs_test.go | 20 +- backend/internal/admin/session.go | 38 +-- backend/internal/api/account.go | 8 +- backend/internal/api/account_test.go | 8 +- backend/internal/api/api.go | 8 +- backend/internal/api/comments.go | 17 +- backend/internal/api/providers.go | 2 +- backend/internal/model/model.go | 10 +- backend/internal/store/store.go | 111 +++++++- frontend/src/admin/AccountView.vue | 50 +++- frontend/src/admin/AdminLayout.vue | 6 + frontend/src/admin/LoginView.vue | 1 + frontend/src/admin/SettingsView.vue | 43 +-- frontend/src/admin/UsersView.vue | 146 ++++++++++ frontend/src/admin/auth.js | 7 +- frontend/src/api.js | 20 +- frontend/src/router.js | 3 +- 23 files changed, 917 insertions(+), 222 deletions(-) create mode 100644 backend/internal/admin/actor.go create mode 100644 frontend/src/admin/UsersView.vue diff --git a/README.md b/README.md index 73c5cfc..628eb3a 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,14 @@ ONE_ADMIN_USER=admin ONE_ADMIN_PASSWORD=换一个 make start 保存后立即生效,无需重启。这些值存在 `settings` 表里,生效规则是 「后台填了用后台的,没填回落到环境变量」——老部署不改 env 也能照常跑。 -秘密项(client secret、Bot token、R2 密钥、管理员密码)在后台只显示 +秘密项(client secret、Bot token、R2 密钥)在后台只显示 「是否已配置、来自哪里」,永不回显明文;留空保存 = 保持现值。 -管理员密码在后台以 bcrypt 哈希存储;显式设置过的 `ONE_ADMIN_PASSWORD` -保留作解锁后路(env 和数据库在同一台机器上,能读 env 的人本来就能改库)。 + +**多用户与角色**:站主(owner,全库唯一)可在后台 **用户** 页创建内容管理员 +(admin),管理员能写文章、管理评论与文件,但系统设置、用户管理与第三方凭据 +只有站主动。密码 bcrypt 存在 users 表上,各账号在「账户」页自行修改; +显式设置过的 `ONE_ADMIN_PASSWORD` 保留作站主的解锁后路, +admin/admin 开发模式在任何账号设置过密码后立即失效。 环境变量只剩启动期必需项: diff --git a/backend/internal/admin/account.go b/backend/internal/admin/account.go index ac49c3a..8ee599a 100644 --- a/backend/internal/admin/account.go +++ b/backend/internal/admin/account.go @@ -34,20 +34,12 @@ type accountView struct { } type passwordInfo struct { - // 密码现在有两个可能的家:settings 表里的 bcrypt 哈希(后台改的), - // 或环境变量 ONE_ADMIN_PASSWORD(未迁移时的兜底)。 + // 多用户后密码统一在 users 行上(bcrypt),在账户页修改。 + // 首次登录时 env / settings 的引导凭据会自迁移成行内哈希。 ManagedBy string `json:"managed_by"` Username string `json:"username"` } -// passwordSource 报告当前密码存哪。前端只作展示,不参与逻辑。 -func passwordSource(hash string) string { - if hash != "" { - return "settings" - } - return "env:ONE_ADMIN_PASSWORD" -} - func (a *API) account(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { httpx.Error(w, http.StatusMethodNotAllowed, "GET required") @@ -79,15 +71,16 @@ func (a *API) buildAccount() (accountView, error) { return accountView{}, err } v := accountView{ + // Providers 必须非 nil:一个第三方都没配时 Go 会序列化成 null, + // 前端 acct.providers.length 直接炸(真实事故:新库未配 OAuth 时账户页白屏) + Providers: []string{}, // 昵称以站主行的 name 为准;老数据里它是空的,回落到站点设置的作者名。 - Name: firstNonEmptyStr(owner.Name, st.AuthorName), - Bio: st.AuthorBio, - AvatarKey: st.AuthorAvatarKey, - AvatarURL: a.avatarURL(st.AuthorAvatarKey), - Handle: owner.Handle, - // 密码可迁到哪:后台「管理员账号」里改过就是 DB(bcrypt 哈希), - // 否则回落 env。 - Password: passwordInfo{ManagedBy: passwordSource(st.AdminPasswordHash), Username: a.cfg().AdminUser}, + Name: firstNonEmptyStr(owner.Name, st.AuthorName), + Bio: st.AuthorBio, + AvatarKey: st.AuthorAvatarKey, + AvatarURL: a.avatarURL(st.AuthorAvatarKey), + Handle: owner.Handle, + Password: passwordInfo{ManagedBy: "account", Username: a.cfg().AdminUser}, Identities: ids, Passkeys: pks, } diff --git a/backend/internal/admin/account_test.go b/backend/internal/admin/account_test.go index f1f449f..338482f 100644 --- a/backend/internal/admin/account_test.go +++ b/backend/internal/admin/account_test.go @@ -11,28 +11,32 @@ import ( "oneblog/internal/model" ) -// doAs 带着有效后台会话发一个请求。 -func doAs(t *testing.T, h http.Handler, method, path string, body string) *httptest.ResponseRecorder { +// doAs 带着有效后台会话(owner)发一个请求。会话 token 只带用户 ID, +// 所以先确保 owner 行存在,再按真实 ID 签。 +func doAs(t *testing.T, a *API, method, path string, body string) *httptest.ResponseRecorder { t.Helper() + owner, err := a.Store.EnsureOwner("admin") + if err != nil { + t.Fatal(err) + } req := httptest.NewRequest(method, path, strings.NewReader(body)) if body != "" { req.Header.Set("Content-Type", "application/json") } - // 用与 newTestAPI 里 NewSessions 相同的 secret 签一个会话 sess := NewSessions("test-secret", time.Hour) - tok, _ := sess.Issue("admin") + tok, _ := sess.Issue(owner.ID) req.AddCookie(&http.Cookie{Name: cookieName, Value: tok}) rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) + a.Routes().ServeHTTP(rec, req) return rec } func TestAccountGET(t *testing.T) { - a, h := newTestAPI(t) + a, _ := newTestAPI(t) if _, err := a.Store.EnsureOwner("admin"); err != nil { t.Fatal(err) } - rec := doAs(t, h, http.MethodGet, "/api/admin/account", "") + rec := doAs(t, a, http.MethodGet, "/api/admin/account", "") if rec.Code != http.StatusOK { t.Fatalf("got %d %s", rec.Code, rec.Body.String()) } @@ -55,11 +59,11 @@ func TestAccountGET(t *testing.T) { } func TestAccountPATCHProfile(t *testing.T) { - a, h := newTestAPI(t) + a, _ := newTestAPI(t) if _, err := a.Store.EnsureOwner("admin"); err != nil { t.Fatal(err) } - rec := doAs(t, h, http.MethodPatch, "/api/admin/account", `{"name":"麻衣","bio":"活着就是为了樱岛麻衣"}`) + rec := doAs(t, a, http.MethodPatch, "/api/admin/account", `{"name":"麻衣","bio":"活着就是为了樱岛麻衣"}`) if rec.Code != http.StatusOK { t.Fatalf("got %d %s", rec.Code, rec.Body.String()) } @@ -81,12 +85,12 @@ func TestAccountPATCHProfile(t *testing.T) { } func TestAccountPATCHAvatarKey(t *testing.T) { - a, h := newTestAPI(t) + a, _ := newTestAPI(t) if _, err := a.Store.EnsureOwner("admin"); err != nil { t.Fatal(err) } // 不存在的 key 必须拒:否则会存下一个永远解析不出的头像 - rec := doAs(t, h, http.MethodPatch, "/api/admin/account", `{"avatar_key":"2026/09/nope.png"}`) + rec := doAs(t, a, http.MethodPatch, "/api/admin/account", `{"avatar_key":"2026/09/nope.png"}`) if rec.Code != http.StatusBadRequest { t.Fatalf("不存在的 key: got %d, want 400", rec.Code) } @@ -98,7 +102,7 @@ func TestAccountPATCHAvatarKey(t *testing.T) { if err != nil { t.Fatal(err) } - rec = doAs(t, h, http.MethodPatch, "/api/admin/account", `{"avatar_key":"`+f.Key+`"}`) + rec = doAs(t, a, http.MethodPatch, "/api/admin/account", `{"avatar_key":"`+f.Key+`"}`) if rec.Code != http.StatusBadRequest { t.Fatalf("非图片: got %d, want 400", rec.Code) } @@ -110,7 +114,7 @@ func TestAccountPATCHAvatarKey(t *testing.T) { if err != nil { t.Fatal(err) } - rec = doAs(t, h, http.MethodPatch, "/api/admin/account", `{"avatar_key":"`+img.Key+`"}`) + rec = doAs(t, a, http.MethodPatch, "/api/admin/account", `{"avatar_key":"`+img.Key+`"}`) if rec.Code != http.StatusOK { t.Fatalf("图片头像: got %d %s", rec.Code, rec.Body.String()) } @@ -143,11 +147,11 @@ func TestAccountRejectsAnonymous(t *testing.T) { } func TestAccountUnbindUnknown(t *testing.T) { - a, h := newTestAPI(t) + a, _ := newTestAPI(t) if _, err := a.Store.EnsureOwner("admin"); err != nil { t.Fatal(err) } - rec := doAs(t, h, http.MethodDelete, "/api/admin/account/identities/github", "") + rec := doAs(t, a, http.MethodDelete, "/api/admin/account/identities/github", "") if rec.Code != http.StatusNotFound { t.Fatalf("没绑过还解绑: got %d, want 404", rec.Code) } @@ -155,11 +159,11 @@ func TestAccountUnbindUnknown(t *testing.T) { // passkey 未配置时必须明确不可用,而不是假装成功 func TestPasskeysUnavailableWhenNil(t *testing.T) { - a, h := newTestAPI(t) + a, _ := newTestAPI(t) if a.Passkeys != nil { t.Skip("测试构造里不该有 Passkeys") } - rec := doAs(t, h, http.MethodPost, "/api/admin/account/passkeys/begin", "") + rec := doAs(t, a, http.MethodPost, "/api/admin/account/passkeys/begin", "") if rec.Code != http.StatusServiceUnavailable { t.Fatalf("got %d, want 503", rec.Code) } diff --git a/backend/internal/admin/actor.go b/backend/internal/admin/actor.go new file mode 100644 index 0000000..5337837 --- /dev/null +++ b/backend/internal/admin/actor.go @@ -0,0 +1,61 @@ +// 会话身份的解析与角色守卫。token 只带用户 ID(防篡改靠 HMAC),角色与 +// 停用状态每请求从 users 表现读——后台改角色 / 停用账号即时生效, +// 不用等 7 天会话过期。 +package admin + +import ( + "context" + "net/http" + + "oneblog/internal/httpx" + "oneblog/internal/model" +) + +type actor struct { + ID int64 + Handle string + Role string +} + +type actorKey struct{} + +func (a *API) guard(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + token := bearer(r) + if token == "" { + if c, err := r.Cookie(cookieName); err == nil { + token = c.Value + } + } + id, err := a.Sessions.Verify(token) + if err != nil { + httpx.Unauthorized(w) + return + } + u, err := a.Store.GetUserByID(id) + // 只认后台账号行:token 是我们签发的,理论上不会指到 reader, + // 但防御式校验一层;停用的账号立即失效。 + if err != nil || u.Provider != "admin" || u.Banned { + httpx.Unauthorized(w) + return + } + act := actor{ID: u.ID, Handle: u.Handle, Role: u.Role} + next(w, r.WithContext(context.WithValue(r.Context(), actorKey{}, act))) + } +} + +// guardOwner 在 guard 之上加角色门槛:系统设置、用户管理只属于站主。 +func (a *API) guardOwner(next http.HandlerFunc) http.HandlerFunc { + return a.guard(func(w http.ResponseWriter, r *http.Request) { + if actorFrom(r).Role != model.RoleOwner { + httpx.Error(w, http.StatusForbidden, "需要站主权限") + return + } + next(w, r) + }) +} + +func actorFrom(r *http.Request) actor { + act, _ := r.Context().Value(actorKey{}).(actor) + return act +} diff --git a/backend/internal/admin/api.go b/backend/internal/admin/api.go index 4918b83..97de515 100644 --- a/backend/internal/admin/api.go +++ b/backend/internal/admin/api.go @@ -92,6 +92,10 @@ func (a *API) Routes() http.Handler { mux.HandleFunc("/api/admin/files/import", a.guard(a.importFiles)) mux.HandleFunc("/api/admin/files/", a.guard(a.fileByID)) mux.HandleFunc("/api/admin/settings", a.guard(a.settings)) + // 用户管理:站主专属(多用户与角色) + mux.HandleFunc("/api/admin/users", a.guardOwner(a.users)) + mux.HandleFunc("/api/admin/users/", a.guardOwner(a.userByID)) + mux.HandleFunc("/api/admin/account/password", a.guard(a.changePassword)) // 账户页:资料 + 身份绑定 + passkey mux.HandleFunc("/api/admin/account", a.guard(func(w http.ResponseWriter, r *http.Request) { switch r.Method { @@ -129,23 +133,7 @@ func (a *API) Routes() http.Handler { return mux } -// guard requires a valid session; the token may arrive as a cookie (browser) -// or as a Bearer token (CLI / API client). -func (a *API) guard(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - token := bearer(r) - if token == "" { - if c, err := r.Cookie(cookieName); err == nil { - token = c.Value - } - } - if token == "" || !a.valid(token) { - httpx.Unauthorized(w) - return - } - next(w, r) - } -} +// guard / actor 解析在 actor.go:token 只带用户 ID,角色每请求从库现读。 func bearer(r *http.Request) string { h := r.Header.Get("Authorization") @@ -155,11 +143,6 @@ func bearer(r *http.Request) string { return "" } -func (a *API) valid(token string) bool { - _, err := a.Sessions.Verify(token) - return err == nil -} - // ---------- auth ---------- type loginRequest struct { @@ -167,38 +150,65 @@ type loginRequest struct { Password string `json:"password"` } -// verifyAdmin 校验登录凭据,返回应发会话的用户名。 -// 密码有两个可能的家:settings 表里的 bcrypt 哈希(后台「管理员账号」里改的, -// 优先),和 ONE_ADMIN_PASSWORD(显式设置时保留作解锁后路——env 和库都在 -// 同一台机器上,能读 env 的人本来就能直接改库,不算额外开口子)。 -// 没显式设 env 密码时是 InsecureDev(admin/admin),一旦后台改过密码就失效。 -func (a *API) verifyAdmin(username, password string) (string, bool) { +// verifyAdmin 校验登录凭据,返回应发会话的后台账号行。 +// +// 第一优先:users 表里的后台账号(owner / admin,行内 bcrypt 哈希)—— +// 多用户体系的主路径,站主在后台改过密码后哈希就在自己那行上。 +// 兜底:引导链。settings 里的哈希(旧版「管理员账号」写入的)或 +// ONE_ADMIN_PASSWORD(显式设置时保留作解锁后路——env 和库都在同一台机器上, +// 能读 env 的人本来就能直接改库);首次登录成功时把引导凭据自迁移成 +// owner 行的哈希,此后引导键不再参与。没显式设 env 密码时是 InsecureDev +// (admin/admin),只在 owner 行还没有哈希时有效——后台一旦设过密码即失效。 +func (a *API) verifyAdmin(username, password string) (model.Reader, bool) { + // 1) 库里后台账号 + if u, err := a.Store.GetStaffByHandle(username); err == nil && !u.Banned && u.PasswordHash != "" { + if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil { + return u, true + } + } + + // 2) 引导链:只认 owner 用户名(settings 覆盖值或 env 默认值) c := a.cfg() - envUser := c.AdminUser - user := envUser - var hash string - if st, err := a.Store.GetSettings(); err == nil { - if st.AdminUsername != "" { - user = st.AdminUsername - } - hash = st.AdminPasswordHash + ownerName := c.AdminUser // Overlay 已把 settings 的 admin_username 叠进来 + owner, oerr := a.Store.EnsureOwner(ownerName) + if oerr != nil { + return model.Reader{}, false } - if hash != "" { - if subtle.ConstantTimeCompare([]byte(username), []byte(user)) == 1 && - bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil { - return user, true + if username != owner.Handle { + return model.Reader{}, false + } + + // env 显式密码:永久后路(无论行内是否有哈希) + if !c.InsecureDev && subtle.ConstantTimeCompare([]byte(password), []byte(c.AdminPass)) == 1 { + a.ensureOwnerHash(owner, password) + return owner, true + } + // 行内还没有哈希 → 首次登录引导:收 settings 哈希或 dev 密码,写行。 + // settings 哈希存在时 dev 密码(admin/admin)不再生效——那是真的库内密码。 + if owner.PasswordHash == "" { + st, serr := a.Store.GetSettings() + hasLegacyHash := serr == nil && st.AdminPasswordHash != "" + if hasLegacyHash && + bcrypt.CompareHashAndPassword([]byte(st.AdminPasswordHash), []byte(password)) == nil { + a.ensureOwnerHash(owner, password) + return owner, true } - // 哈希只对 DB 用户名生效;env 密码作后路时继续走下面的比对 - if c.InsecureDev { - return "", false + if !hasLegacyHash && c.InsecureDev && password == c.AdminPass { + a.ensureOwnerHash(owner, password) + return owner, true } } - userOK := subtle.ConstantTimeCompare([]byte(username), []byte(envUser)) == 1 - passOK := subtle.ConstantTimeCompare([]byte(password), []byte(c.AdminPass)) == 1 - if !userOK || !passOK { - return "", false + return model.Reader{}, false +} + +// ensureOwnerHash 把引导凭据落成 owner 行的 bcrypt 哈希(自迁移)。 +// 走 EnsureStaffHash:只在行内为空时写,不覆盖后台改过的密码。 +func (a *API) ensureOwnerHash(owner model.Reader, password string) { + h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return } - return envUser, true + _ = a.Store.EnsureStaffHash(owner.ID, string(h)) } func (a *API) login(w http.ResponseWriter, r *http.Request) { @@ -223,7 +233,7 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) { return } a.limiter().Reset(key) - token, exp := a.Sessions.Issue(user) + token, exp := a.Sessions.Issue(user.ID) http.SetCookie(w, &http.Cookie{ Name: cookieName, Value: token, @@ -234,7 +244,7 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) { Expires: exp, MaxAge: a.Sessions.TTL(), }) - httpx.OK(w, map[string]any{"token": token, "expires_at": exp.UTC().Format(rfc3339)}) + httpx.OK(w, map[string]any{"token": token, "expires_at": exp.UTC().Format(rfc3339), "role": user.Role, "user": user.Handle}) } const rfc3339 = "2006-01-02T15:04:05Z07:00" @@ -253,22 +263,197 @@ func (a *API) logout(w http.ResponseWriter, r *http.Request) { } func (a *API) me(w http.ResponseWriter, r *http.Request) { - // 会话里带着登录时的用户名(后台可改 admin_username,不能只看配置) - name := "" - if token := bearer(r); token != "" { - if u, err := a.Sessions.Verify(token); err == nil { - name = u + // guard 已解析过 actor;这里直接回带角色,前端据此显隐「用户管理」等入口 + act := actorFrom(r) + httpx.OK(w, map[string]any{"user": act.Handle, "role": act.Role}) +} + +// ---------- users(多用户管理,站主专属) ---------- + +// users:GET 列表 / POST 创建内容管理员。 +func (a *API) users(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + list, err := a.Store.ListStaff() + if err != nil { + httpx.ServerError(w, err) + return } - } else if c, err := r.Cookie(cookieName); err == nil { - if u, err := a.Sessions.Verify(c.Value); err == nil { - name = u + httpx.OK(w, map[string]any{"users": list}) + case http.MethodPost: + var in struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := httpx.Decode(r, &in); err != nil { + httpx.BadRequest(w, "invalid body") + return + } + name := strings.TrimSpace(in.Username) + if !validStaffName(name) { + httpx.BadRequest(w, "用户名限 2-32 位,字母 / 数字 / _ - . @") + return + } + if len(in.Password) < 6 || len(in.Password) > 72 { + httpx.BadRequest(w, "密码长度需在 6-72 位之间") + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost) + if err != nil { + httpx.ServerError(w, err) + return + } + u, err := a.Store.CreateStaff(name, string(hash)) + if errors.Is(err, store.ErrConflict) { + httpx.Error(w, http.StatusConflict, "用户名已被占用") + return + } + if err != nil { + httpx.ServerError(w, err) + return + } + httpx.Created(w, u) + default: + httpx.Error(w, http.StatusMethodNotAllowed, "GET/POST required") + } +} + +// userByID:PATCH(重置密码 / 停用恢复)/ DELETE。owner 行不可动,自己 +// 不能停用自己(会把自己锁在会话外面)。 +func (a *API) userByID(w http.ResponseWriter, r *http.Request) { + id, err := parseInt(strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/users/"), "/")) + if err != nil || id <= 0 { + httpx.BadRequest(w, "bad user id") + return + } + target, terr := a.Store.GetUserByID(id) + if terr != nil || target.Provider != "admin" { + httpx.NotFound(w) + return + } + if target.Role == model.RoleOwner { + httpx.BadRequest(w, "站主账号不可在此修改") + return + } + act := actorFrom(r) + + switch r.Method { + case http.MethodPatch: + var in struct { + Password *string `json:"password"` + Banned *bool `json:"banned"` + } + if err := httpx.Decode(r, &in); err != nil { + httpx.BadRequest(w, "invalid body") + return + } + if in.Password != nil && *in.Password != "" { + if len(*in.Password) < 6 || len(*in.Password) > 72 { + httpx.BadRequest(w, "密码长度需在 6-72 位之间") + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(*in.Password), bcrypt.DefaultCost) + if err != nil { + httpx.ServerError(w, err) + return + } + if err := a.Store.SetStaffPassword(id, string(hash)); err != nil { + httpx.ServerError(w, err) + return + } + } + if in.Banned != nil { + if *in.Banned && id == act.ID { + httpx.BadRequest(w, "不能停用自己") + return + } + if err := a.Store.SetStaffBanned(id, *in.Banned); err != nil { + httpx.ServerError(w, err) + return + } + } + u, err := a.Store.GetUserByID(id) + if err != nil { + httpx.NotFound(w) + return + } + httpx.OK(w, u) + case http.MethodDelete: + if id == act.ID { + httpx.BadRequest(w, "不能删除自己") + return + } + if err := a.Store.DeleteStaff(id); err != nil { + if errors.Is(err, store.ErrNotFound) { + httpx.NotFound(w) + return + } + httpx.ServerError(w, err) + return + } + httpx.OK(w, map[string]any{"ok": true}) + default: + httpx.Error(w, http.StatusMethodNotAllowed, "PATCH/DELETE required") + } +} + +// validStaffName 用户名白名单:2-32 位,字母数字与 _ - . @。 +func validStaffName(s string) bool { + if len(s) < 2 || len(s) > 32 { + return false + } + for _, c := range s { + ok := c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || + c == '_' || c == '-' || c == '.' || c == '@' + if !ok { + return false } } - if name == "" { + return true +} + +// changePassword 自己改自己的密码(owner / admin 通用)。 +// 旧密码只在行内已有哈希时校验——OAuth / Passkey 直接登录、从没设过密码的 +// 账号第一次设密码不需要旧密码。 +func (a *API) changePassword(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch && r.Method != http.MethodPut { + httpx.Error(w, http.StatusMethodNotAllowed, "PATCH required") + return + } + var in struct { + Old string `json:"old_password"` + New string `json:"new_password"` + } + if err := httpx.Decode(r, &in); err != nil { + httpx.BadRequest(w, "invalid body") + return + } + if len(in.New) < 6 || len(in.New) > 72 { + httpx.BadRequest(w, "新密码长度需在 6-72 位之间") + return + } + act := actorFrom(r) + u, err := a.Store.GetUserByID(act.ID) + if err != nil { httpx.Unauthorized(w) return } - httpx.OK(w, map[string]any{"user": name}) + if u.PasswordHash != "" { + if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(in.Old)) != nil { + httpx.Error(w, http.StatusForbidden, "旧密码不对") + return + } + } + hash, err := bcrypt.GenerateFromPassword([]byte(in.New), bcrypt.DefaultCost) + if err != nil { + httpx.ServerError(w, err) + return + } + if err := a.Store.SetStaffPassword(u.ID, string(hash)); err != nil { + httpx.ServerError(w, err) + return + } + httpx.OK(w, map[string]any{"ok": true}) } // ---------- posts ---------- @@ -611,6 +796,19 @@ func (a *API) credentialMeta() (map[string]any, error) { out := map[string]any{} for _, ck := range credKeys { src := "unset" + if ck.key == "admin_password_hash" { + // 多用户后密码在 owner 行上;settings 键只是旧版遗留引导, + // 行上有哈希就以此为准 + if owner, err := a.Store.EnsureOwner(c.AdminUser); err == nil && owner.PasswordHash != "" { + src = "db" + } else if v, ok := m[ck.key]; ok && strings.TrimSpace(v) != "" { + src = "db" + } else { + src = "env" // env 显式密码或 dev 默认,都算「有生效值」 + } + out[ck.key] = map[string]any{"set": src != "unset", "source": src} + continue + } if v, ok := m[ck.key]; ok && strings.TrimSpace(v) != "" { src = "db" } else if ck.envOf(c) != "" { @@ -653,37 +851,27 @@ func (a *API) settings(w http.ResponseWriter, r *http.Request) { } httpx.OK(w, map[string]any{"settings": st, "credential_meta": meta}) case http.MethodPut, http.MethodPost: + // 系统设置(含凭据与管理员用户名)是站主的权限;内容管理员只读 + if act := actorFrom(r); act.Role != model.RoleOwner { + httpx.Error(w, http.StatusForbidden, "需要站主权限") + return + } var in settingsPut if err := httpx.Decode(r, &in); err != nil { httpx.BadRequest(w, "invalid body") return } - // admin_password 走单独通道:明文只在请求里出现一次,落库前哈希 + // 密码变更不走这里:多用户后密码在 users 行上, + // 自己改走 /api/admin/account/password,重置别人走 /api/admin/users/{id} for k, v := range in.Secrets { v = strings.TrimSpace(v) if v == "" { continue // 留空 = 不改 } - if !writableSecrets[k] && k != "admin_password" { + if !writableSecrets[k] { httpx.BadRequest(w, "unknown secret key: "+k) return } - if k == "admin_password" { - if len(v) < 6 || len(v) > 72 { - httpx.BadRequest(w, "密码长度需在 6-72 位之间") - return - } - h, err := bcrypt.GenerateFromPassword([]byte(v), bcrypt.DefaultCost) - if err != nil { - httpx.ServerError(w, err) - return - } - if err := a.Store.SetSetting("admin_password_hash", string(h)); err != nil { - httpx.ServerError(w, err) - return - } - continue - } if err := a.Store.SetSetting(k, v); err != nil { httpx.ServerError(w, err) return diff --git a/backend/internal/admin/api_test.go b/backend/internal/admin/api_test.go index dd50216..15d6759 100644 --- a/backend/internal/admin/api_test.go +++ b/backend/internal/admin/api_test.go @@ -242,18 +242,24 @@ func TestSettingsCredentialsRoundTrip(t *testing.T) { return rec } - // 写入 DB 值(含一个 secret);响应与 GET 都不能回显 secret 明文 + // 写入 DB 值(含一个 secret);响应与 GET 都不能回显 secret 明文。 + // 密码不再走 settings(多用户后在 users 行上),改走 account/password 端点。 rec := put(`{"site_url":"https://db.example","github_client_id":"db-id", - "secrets":{"github_client_secret":"db-sec","admin_password":"newpass1"}}`) + "secrets":{"github_client_secret":"db-sec"}}`) if rec.Code != http.StatusOK { t.Fatalf("put: got %d body=%s", rec.Code, rec.Body.String()) } - if strings.Contains(rec.Body.String(), "db-sec") || strings.Contains(rec.Body.String(), "newpass1") { + if strings.Contains(rec.Body.String(), "db-sec") { t.Fatal("secret echoed back in plaintext") } + // admin_password 已退役:出现即 400 + if rec := put(`{"secrets":{"admin_password":"newpass1"}}`); rec.Code != http.StatusBadRequest { + t.Errorf("legacy admin_password: got %d, want 400", rec.Code) + } // 来源标记:site_url 来自 db,client_id 来自 db,secret 来自 db; - // 未写的项回落 env + // 未写的项回落 env;密码此时还在 env 引导链上(首次登录已自迁移成行哈希, + // 来源也是 db) if meta, err := a.credentialMeta(); err != nil { t.Fatal(err) } else { @@ -279,9 +285,23 @@ func TestSettingsCredentialsRoundTrip(t *testing.T) { t.Errorf("secret overlay failed: %q", c.GitHubClientSecret) } - // DB 密码立即生效;显式设置的 env 密码仍作后路;错误的都不行 + // 自己改密码:旧密码错被拒,对了立即生效;显式 env 密码仍作后路 + patch := func(body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPatch, "/api/admin/account/password", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+sess.Token) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec + } + if rec := patch(`{"old_password":"wrong","new_password":"newpass1"}`); rec.Code != http.StatusForbidden { + t.Errorf("wrong old password: got %d, want 403", rec.Code) + } + if rec := patch(`{"old_password":"s3cret","new_password":"newpass1"}`); rec.Code != http.StatusOK { + t.Errorf("change password: got %d body=%s", rec.Code, rec.Body.String()) + } if _, ok := a.verifyAdmin("admin", "newpass1"); !ok { - t.Error("db password should work right after save") + t.Error("row password should work right after change") } if _, ok := a.verifyAdmin("admin", "s3cret"); !ok { t.Error("explicit env password should stay as backstop") @@ -289,30 +309,33 @@ func TestSettingsCredentialsRoundTrip(t *testing.T) { if _, ok := a.verifyAdmin("admin", "wrong"); ok { t.Error("wrong password must fail") } - - // admin_password 6 位下限 - if rec := put(`{"secrets":{"admin_password":"123"}}`); rec.Code != http.StatusBadRequest { - t.Errorf("short password: got %d, want 400", rec.Code) - } } // InsecureDev(env 未显式设密码)时 admin/admin 有效,但后台一旦改了密码 // admin/admin 必须立刻失效。 +// InsecureDev(env 未显式设密码)时 admin/admin 有效;首次登录会把引导凭据 +// 自迁移成 owner 行哈希——之后站主改了密码(行哈希更新),admin/admin 即失效。 func TestInsecureDevDisabledByDBPassword(t *testing.T) { a, _ := newTestAPI(t) a.Cfg = &config.Config{AdminUser: "admin", AdminPass: "admin", InsecureDev: true} if _, ok := a.verifyAdmin("admin", "admin"); !ok { t.Fatal("insecure default should work before any password is set") } - h := bcryptHash(t, "newpass1") - if err := a.Store.SetSetting("admin_password_hash", h); err != nil { + owner, err := a.Store.EnsureOwner("admin") + if err != nil { + t.Fatal(err) + } + if owner.PasswordHash == "" { + t.Fatal("first login should migrate bootstrap credentials onto the owner row") + } + if err := a.Store.SetStaffPassword(owner.ID, bcryptHash(t, "newpass1")); err != nil { t.Fatal(err) } if _, ok := a.verifyAdmin("admin", "admin"); ok { - t.Error("admin/admin must stop working once a DB password exists") + t.Error("admin/admin must stop working once a row password exists") } if _, ok := a.verifyAdmin("admin", "newpass1"); !ok { - t.Error("db password should be accepted") + t.Error("row password should be accepted") } } @@ -324,3 +347,109 @@ func bcryptHash(t *testing.T, pw string) string { } return string(b) } + +// 多用户与角色:owner 创建 admin 账号;admin 能进内容接口, +// 动不了系统设置与用户管理;被停用后存量会话立即失效。 +func TestMultiUserLifecycle(t *testing.T) { + _, h := newTestAPI(t) + tokOf := func(user, pass string) string { + rec := login(t, h, user, pass) + var out struct { + Token string `json:"token"` + Role string `json:"role"` + } + if rec.Code != http.StatusOK { + t.Fatalf("login %s: got %d body=%s", user, rec.Code, rec.Body.String()) + } + if err := json.NewDecoder(rec.Body).Decode(&out); err != nil || out.Token == "" { + t.Fatalf("login %s: no token: %v", user, err) + } + if user == "admin" && out.Role != model.RoleOwner { + t.Errorf("owner role = %q", out.Role) + } + return out.Token + } + reqAs := func(tok, method, path, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+tok) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec + } + owner := tokOf("admin", "s3cret") + + // owner 创建内容管理员 + rec := reqAs(owner, http.MethodPost, "/api/admin/users", `{"username":"editor","password":"editor1"}`) + if rec.Code != http.StatusCreated { + t.Fatalf("create staff: got %d body=%s", rec.Code, rec.Body.String()) + } + // 用户名占用 + if rec := reqAs(owner, http.MethodPost, "/api/admin/users", `{"username":"editor","password":"editor1"}`); rec.Code != http.StatusConflict { + t.Errorf("duplicate username: got %d, want 409", rec.Code) + } + + // staff 登录,角色是 admin + staff := tokOf("editor", "editor1") + + // 内容接口可进 + if rec := reqAs(staff, http.MethodGet, "/api/admin/posts", ""); rec.Code != http.StatusOK { + t.Errorf("staff read posts: got %d", rec.Code) + } + // 系统设置写不了、用户管理进不去 + if rec := reqAs(staff, http.MethodPut, "/api/admin/settings", `{"site_title":"x"}`); rec.Code != http.StatusForbidden { + t.Errorf("staff put settings: got %d, want 403", rec.Code) + } + if rec := reqAs(staff, http.MethodGet, "/api/admin/users", ""); rec.Code != http.StatusForbidden { + t.Errorf("staff list users: got %d, want 403", rec.Code) + } + if rec := reqAs(staff, http.MethodPost, "/api/admin/users", `{"username":"x2","password":"xxxxxx"}`); rec.Code != http.StatusForbidden { + t.Errorf("staff create user: got %d, want 403", rec.Code) + } + + // owner 重置 staff 密码后,新密码立即生效 + staffID := 0 + list := reqAs(owner, http.MethodGet, "/api/admin/users", "") + var lu struct { + Users []model.Reader `json:"users"` + } + _ = json.NewDecoder(list.Body).Decode(&lu) + for _, u := range lu.Users { + if u.Handle == "editor" { + staffID = int(u.ID) + if u.Role != model.RoleAdmin { + t.Errorf("staff role = %q, want admin", u.Role) + } + } + } + if staffID == 0 { + t.Fatal("editor not in staff list") + } + if rec := reqAs(owner, http.MethodPatch, "/api/admin/users/"+itoa(int64(staffID)), + `{"password":"reset99"}`); rec.Code != http.StatusOK { + t.Errorf("reset password: got %d", rec.Code) + } + if rec := login(t, h, "editor", "reset99"); rec.Code != http.StatusOK { + t.Errorf("login with reset password: got %d", rec.Code) + } + + // 停用后存量会话立即 401;owner 行与自身不可停用 / 不可删 + if rec := reqAs(owner, http.MethodPatch, "/api/admin/users/"+itoa(int64(staffID)), `{"banned":true}`); rec.Code != http.StatusOK { + t.Fatalf("ban staff: got %d", rec.Code) + } + if rec := reqAs(staff, http.MethodGet, "/api/admin/posts", ""); rec.Code != http.StatusUnauthorized { + t.Errorf("banned staff session: got %d, want 401", rec.Code) + } + if rec := login(t, h, "editor", "reset99"); rec.Code != http.StatusUnauthorized { + t.Errorf("banned staff login: got %d, want 401", rec.Code) + } + if rec := reqAs(owner, http.MethodDelete, "/api/admin/users/"+itoa(int64(staffID)), ""); rec.Code != http.StatusOK { + t.Errorf("delete staff: got %d", rec.Code) + } + // owner 行自我保护 + if rec := reqAs(owner, http.MethodDelete, "/api/admin/users/1", ""); rec.Code == http.StatusOK { + t.Error("owner row must not be deletable") + } +} diff --git a/backend/internal/admin/file_refs_test.go b/backend/internal/admin/file_refs_test.go index 56a5008..8d7a29e 100644 --- a/backend/internal/admin/file_refs_test.go +++ b/backend/internal/admin/file_refs_test.go @@ -41,7 +41,7 @@ func seed(t *testing.T, a *API, key, body string) model.File { } func TestFileRefsEndpoint(t *testing.T) { - a, h := newTestAPI(t) + a, _ := newTestAPI(t) a.Blobs = storage.NewLocal(t.TempDir()) f := seed(t, a, "2026/09/aaa.png", `看图 ![](/uploads/2026/09/aaa.png)`) // 顺手把站主头像也指到同一张图,refs 要把这一路也报出来 @@ -49,7 +49,7 @@ func TestFileRefsEndpoint(t *testing.T) { t.Fatal(err) } - rec := doAs(t, h, http.MethodGet, "/api/admin/files/"+itoa(f.ID)+"/refs", "") + rec := doAs(t, a, http.MethodGet, "/api/admin/files/"+itoa(f.ID)+"/refs", "") if rec.Code != http.StatusOK { t.Fatalf("got %d %s", rec.Code, rec.Body.String()) } @@ -73,12 +73,12 @@ func TestFileRefsEndpoint(t *testing.T) { } // 不存在的文件:404 而不是空列表 - rec = doAs(t, h, http.MethodGet, "/api/admin/files/9999/refs", "") + rec = doAs(t, a, http.MethodGet, "/api/admin/files/9999/refs", "") if rec.Code != http.StatusNotFound { t.Fatalf("want 404, got %d", rec.Code) } // 乱七八糟的子路径不收 - rec = doAs(t, h, http.MethodGet, "/api/admin/files/1/nope", "") + rec = doAs(t, a, http.MethodGet, "/api/admin/files/1/nope", "") if rec.Code != http.StatusBadRequest { t.Fatalf("want 400, got %d", rec.Code) } @@ -86,12 +86,12 @@ func TestFileRefsEndpoint(t *testing.T) { // 有引用时默认挡住,而且挡住之后 blob 和行都得还在(可重试)。 func TestFileDeleteBlockedByRefs(t *testing.T) { - a, h := newTestAPI(t) + a, _ := newTestAPI(t) dir := t.TempDir() a.Blobs = storage.NewLocal(dir) f := seed(t, a, "2026/09/bbb.png", `![](/uploads/2026/09/bbb.png)`) - rec := doAs(t, h, http.MethodDelete, "/api/admin/files/"+itoa(f.ID), "") + rec := doAs(t, a, http.MethodDelete, "/api/admin/files/"+itoa(f.ID), "") if rec.Code != http.StatusConflict { t.Fatalf("want 409, got %d %s", rec.Code, rec.Body.String()) } @@ -114,12 +114,12 @@ func TestFileDeleteBlockedByRefs(t *testing.T) { // 明确带 force=1 才真删:行、blob 一起走。 func TestFileDeleteForceProceeds(t *testing.T) { - a, h := newTestAPI(t) + a, _ := newTestAPI(t) dir := t.TempDir() a.Blobs = storage.NewLocal(dir) f := seed(t, a, "2026/09/ccc.png", `![](/uploads/2026/09/ccc.png)`) - rec := doAs(t, h, http.MethodDelete, "/api/admin/files/"+itoa(f.ID)+"?force=1", "") + rec := doAs(t, a, http.MethodDelete, "/api/admin/files/"+itoa(f.ID)+"?force=1", "") if rec.Code != http.StatusOK { t.Fatalf("want 200, got %d %s", rec.Code, rec.Body.String()) } @@ -133,11 +133,11 @@ func TestFileDeleteForceProceeds(t *testing.T) { // 没被引用的文件不用 force 也能删(守卫不能把所有删除都挡死)。 func TestFileDeleteUnreferenced(t *testing.T) { - a, h := newTestAPI(t) + a, _ := newTestAPI(t) a.Blobs = storage.NewLocal(t.TempDir()) f := seed(t, a, "2026/09/ddd.png", "") - rec := doAs(t, h, http.MethodDelete, "/api/admin/files/"+itoa(f.ID), "") + rec := doAs(t, a, http.MethodDelete, "/api/admin/files/"+itoa(f.ID), "") if rec.Code != http.StatusOK { t.Fatalf("want 200, got %d %s", rec.Code, rec.Body.String()) } diff --git a/backend/internal/admin/session.go b/backend/internal/admin/session.go index d0269d6..0c3c71f 100644 --- a/backend/internal/admin/session.go +++ b/backend/internal/admin/session.go @@ -11,8 +11,10 @@ import ( "time" ) -// Sessions are stateless: base64("user:expiryUnix") + "." + HMAC-SHA256. -// They survive restarts as long as ONE_SECRET stays the same. +// Sessions are stateless: base64("user::expiryUnix") + "." + HMAC-SHA256. +// They survive restarts as long as ONE_SECRET stays the same. The token only +// carries the user's DB id — role / ban state is read fresh from the database +// on every request, so demotions and bans take effect immediately. type Sessions struct { secret []byte ttl time.Duration @@ -27,37 +29,41 @@ func NewSessions(secret string, ttl time.Duration) *Sessions { var ErrBadSession = errors.New("invalid session") -func (s *Sessions) Issue(user string) (string, time.Time) { +func (s *Sessions) Issue(userID int64) (string, time.Time) { exp := time.Now().Add(s.ttl) - payload := base64.RawURLEncoding.EncodeToString([]byte(user + ":" + strconv.FormatInt(exp.Unix(), 10))) + payload := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("user:%d:%d", userID, exp.Unix()))) return payload + "." + s.sign(payload), exp } -func (s *Sessions) Verify(token string) (string, error) { +func (s *Sessions) Verify(token string) (int64, error) { parts := strings.Split(token, ".") if len(parts) != 2 { - return "", ErrBadSession + return 0, ErrBadSession } if !hmac.Equal([]byte(s.sign(parts[0])), []byte(parts[1])) { - return "", ErrBadSession + return 0, ErrBadSession } raw, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil { - return "", ErrBadSession + return 0, ErrBadSession } - i := strings.LastIndex(string(raw), ":") - if i <= 0 { - return "", ErrBadSession + // user:: + f := strings.Split(string(raw), ":") + if len(f) != 3 || f[0] != "user" { + return 0, ErrBadSession } - user := string(raw)[:i] - expUnix, err := strconv.ParseInt(string(raw)[i+1:], 10, 64) + id, err := strconv.ParseInt(f[1], 10, 64) + if err != nil || id <= 0 { + return 0, ErrBadSession + } + expUnix, err := strconv.ParseInt(f[2], 10, 64) if err != nil { - return "", ErrBadSession + return 0, ErrBadSession } if time.Now().After(time.Unix(expUnix, 0)) { - return "", ErrBadSession + return 0, ErrBadSession } - return user, nil + return id, nil } func (s *Sessions) sign(payload string) string { diff --git a/backend/internal/api/account.go b/backend/internal/api/account.go index 4b39438..0329d52 100644 --- a/backend/internal/api/account.go +++ b/backend/internal/api/account.go @@ -142,7 +142,7 @@ func (a *API) afterIdentity(w http.ResponseWriter, r *http.Request, provider, ex u, err := a.Store.GetUserByIdentity(provider, externUID) switch { case err == nil && u.Role == model.RoleOwner: - a.issueAdminSession(w, r) + a.issueAdminSession(w, r, u.ID) return "" case err == nil: return a.issueReaderSession(w, r, u.ID) @@ -186,8 +186,8 @@ func (a *API) loginBack(w http.ResponseWriter, r *http.Request) string { // issueAdminSession 让已绑定的第三方身份直接换发后台会话 —— 「绑定即提权」 // 的落点。Secure / SameSite 与密码登录发的 cookie 完全一致,否则 HTTPS 下 // 浏览器会把它当不安全 cookie 丢掉。 -func (a *API) issueAdminSession(w http.ResponseWriter, r *http.Request) { - token, exp := a.AdminSessions.Issue(a.cfg().AdminUser) +func (a *API) issueAdminSession(w http.ResponseWriter, r *http.Request, userID int64) { + token, exp := a.AdminSessions.Issue(userID) http.SetCookie(w, &http.Cookie{ Name: adminCookieName, Value: token, Path: "/", HttpOnly: true, Secure: isTLS(r), SameSite: http.SameSiteLaxMode, @@ -271,7 +271,7 @@ func (a *API) passkeyFinish(w http.ResponseWriter, r *http.Request) { return } if u.Role == model.RoleOwner { - a.issueAdminSession(w, r) + a.issueAdminSession(w, r, u.ID) httpx.OK(w, map[string]any{"ok": true, "role": u.Role}) return } diff --git a/backend/internal/api/account_test.go b/backend/internal/api/account_test.go index d4d5298..b5a8d89 100644 --- a/backend/internal/api/account_test.go +++ b/backend/internal/api/account_test.go @@ -17,13 +17,13 @@ import ( // 两个 API 之间不该为了测试互相依赖。 type fakeAdmin struct{ valid map[string]bool } -func (f fakeAdmin) Verify(token string) (string, error) { +func (f fakeAdmin) Verify(token string) (int64, error) { if f.valid[token] { - return "admin", nil + return 1, nil } - return "", errors.New("bad session") + return 0, errors.New("bad session") } -func (f fakeAdmin) Issue(string) (string, time.Time) { +func (f fakeAdmin) Issue(int64) (string, time.Time) { return "issued-admin-token", time.Now().Add(time.Hour) } func (f fakeAdmin) TTL() int { return 3600 } diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 7b9bfb0..753c2b5 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -38,11 +38,11 @@ type API struct { // 评论区读者会话与 GitHub OAuth(main.go 装配) ReaderSessions *auth.ReaderSessions // AdminSessions 是后台管理员会话(admin.Sessions 满足它)。 - // 前台访客登录时命中「已绑定给站主」的身份就靠它发后台会话, - // 所以除了 Verify 还要 Issue/TTL。 + // 前台访客登录时命中「已绑定给站主」的身份就靠它发后台会话。 + // token 只带用户 ID(多用户后角色每请求查库,改角色 / 停用即时生效)。 AdminSessions interface { - Verify(token string) (string, error) - Issue(user string) (string, time.Time) + Verify(token string) (int64, error) + Issue(userID int64) (string, time.Time) TTL() int } // Passkeys 是 WebAuthn 服务(main.go 装配;nil 表示未启用,路由不开放) diff --git a/backend/internal/api/comments.go b/backend/internal/api/comments.go index 28526b1..1f376d2 100644 --- a/backend/internal/api/comments.go +++ b/backend/internal/api/comments.go @@ -42,7 +42,8 @@ func (a *API) readerID(r *http.Request) (int64, bool) { } // resolveReader 解出当前访客的读者身份:读者会话优先, -// 其次是后台管理员会话(自动 upsert 一个 provider=admin 的站主读者)。 +// 其次是后台管理员会话——管理员用自己 users 行上的身份发言 +// (多用户后 owner / admin 各自署名,不再都挂在站主名下)。 func (a *API) resolveReader(r *http.Request) (model.Reader, bool, error) { if ck, err := r.Cookie(auth.ReaderCookie); err == nil && ck.Value != "" { if id, verr := a.ReaderSessions.Verify(ck.Value); verr == nil { @@ -54,9 +55,17 @@ func (a *API) resolveReader(r *http.Request) (model.Reader, bool, error) { } if a.AdminSessions != nil { if ck, err := r.Cookie("one_session"); err == nil && ck.Value != "" { - if _, verr := a.AdminSessions.Verify(ck.Value); verr == nil { - rd, oerr := a.ownerReader() - if oerr == nil { + if id, verr := a.AdminSessions.Verify(ck.Value); verr == nil { + rd, gerr := a.Store.GetReader(id) + // 行被删 / 被停用的后台账号:会话当作不存在, + // 不能落到站主身份上顶名发言 + if gerr == nil && rd.Provider == "admin" && !rd.Banned { + if rd.Name == "" { + // 首次发言补一次署名(站点作者名) + if filled, ferr := a.ownerReader(); ferr == nil { + return filled, true, nil + } + } return rd, true, nil } } diff --git a/backend/internal/api/providers.go b/backend/internal/api/providers.go index caebcda..fd1d557 100644 --- a/backend/internal/api/providers.go +++ b/backend/internal/api/providers.go @@ -172,7 +172,7 @@ func (a *API) telegramAuth(w http.ResponseWriter, r *http.Request) { // 已绑定的身份优先:站主用绑定的 Telegram 登录要拿到后台会话 if u, err := a.Store.GetUserByIdentity("telegram", externUID); err == nil { if u.Role == model.RoleOwner { - a.issueAdminSession(w, r) + a.issueAdminSession(w, r, u.ID) httpx.OK(w, map[string]any{"ok": true, "role": u.Role}) return } diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go index 689b46d..52a3a4e 100644 --- a/backend/internal/model/model.go +++ b/backend/internal/model/model.go @@ -264,9 +264,11 @@ type Settings struct { AdminPasswordHash string `json:"-"` } -// 账号角色。owner 全库唯一(站主),reader 是评论区登录进来的访客。 +// 账号角色。owner 全库唯一(站主),admin 是站主在后台创建的内容管理员 +// (可管内容、不可动系统设置与用户),reader 是评论区登录进来的访客。 const ( RoleOwner = "owner" + RoleAdmin = "admin" RoleReader = "reader" ) @@ -281,8 +283,12 @@ type Reader struct { AvatarURL string `json:"avatar_url"` URL string `json:"url"` Banned bool `json:"banned"` - // Role 区分站主与访客:绑定到 owner 的第三方身份登录时会话升级为管理员。 + // Role 区分站主、内容管理员与访客:绑定到 owner 的第三方身份登录时 + // 会话升级为管理员。 Role string `json:"role"` + // PasswordHash 只属于后台账号(provider=admin,owner/admin 两级), + // bcrypt 哈希从不离开服务端;reader 恒为空串。 + PasswordHash string `json:"-"` // CommentCount 是累计评论数(后台用户列表展示用) CommentCount int64 `json:"comment_count"` CreatedAt string `json:"created_at"` diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index f272211..8ebb949 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -166,6 +166,9 @@ func (s *Store) migrate() error { `ALTER TABLE posts ADD COLUMN link_card TEXT NOT NULL DEFAULT ''`, // 账号角色:owner(站主,全库唯一)/ reader(评论区访客) `ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'reader'`, + // 后台账号密码(provider=admin 的行才有值):多用户改造后密码上库, + // env / settings 只作首次引导 + `ALTER TABLE users ADD COLUMN password_hash TEXT NOT NULL DEFAULT ''`, } for _, q := range columnAdds { if _, err := s.db.Exec(s.db.Q(q)); err != nil && !strings.Contains(err.Error(), "already exists") && @@ -1809,11 +1812,12 @@ func (s *Store) UpsertReader(r model.Reader) (model.Reader, error) { } // userCols 是 users 表的读取列清单。列在多处 SELECT 复用,抽出来免得加一列 -// 就要同步改一遍(漏一处就是扫错位置)。 -const userCols = `id,provider,handle,name,avatar_url,url,banned,role,created_at` +// 就要同步改一遍(漏一处就是扫错位置)。password_hash 只在后台账号路径用, +// reader 恒为空串,带上无害。 +const userCols = `id,provider,handle,name,avatar_url,url,banned,role,password_hash,created_at` // userColsU 是 JOIN 查询里带 u. 前缀的同一份列清单。和 userCols 成对改。 -const userColsU = `u.id,u.provider,u.handle,u.name,u.avatar_url,u.url,u.banned,u.role,u.created_at` +const userColsU = `u.id,u.provider,u.handle,u.name,u.avatar_url,u.url,u.banned,u.role,u.password_hash,u.created_at` func (s *Store) GetReader(id int64) (model.Reader, error) { r, err := scanReader(s.db.QueryRow(s.db.Q(`SELECT `+userCols+` FROM users WHERE id = ?`), id)) @@ -1916,6 +1920,105 @@ func (s *Store) GetOwner() (model.Reader, error) { return r, err } +// ---------- 后台账号(多用户):provider=admin 的行,owner / admin 两级 ---------- + +const staffCols = `id,provider,handle,name,avatar_url,url,banned,role,password_hash,created_at` + +func scanStaff(sc interface{ Scan(...any) error }) (model.Reader, error) { + var u model.Reader + if err := sc.Scan(&u.ID, &u.Provider, &u.Handle, &u.Name, &u.AvatarURL, &u.URL, + &u.Banned, &u.Role, &u.PasswordHash, &u.CreatedAt); err != nil { + return model.Reader{}, err + } + return u, nil +} + +// ListStaff 后台账号列表:站主在前、其余按创建时间。 +func (s *Store) ListStaff() ([]model.Reader, error) { + rows, err := s.db.Query(s.db.Q(`SELECT ` + staffCols + ` FROM users + WHERE provider = 'admin' ORDER BY CASE WHEN role = 'owner' THEN 0 ELSE 1 END, created_at`)) + if err != nil { + return nil, err + } + defer rows.Close() + out := []model.Reader{} + for rows.Next() { + u, err := scanStaff(rows) + if err != nil { + return nil, err + } + out = append(out, u) + } + return out, rows.Err() +} + +// GetStaffByHandle 按用户名取后台账号(登录用)。只在带密码哈希的行上有意义。 +func (s *Store) GetStaffByHandle(handle string) (model.Reader, error) { + u, err := scanStaff(s.db.QueryRow(s.db.Q(`SELECT `+staffCols+` FROM users + WHERE provider = 'admin' AND handle = ?`), handle)) + if errors.Is(err, sql.ErrNoRows) { + return model.Reader{}, ErrNotFound + } + return u, err +} + +// GetUserByID 按主键取任意用户(含 reader)——会话校验用:token 只带 ID, +// 角色每请求从库里现读,改角色 / 禁用即时生效。 +func (s *Store) GetUserByID(id int64) (model.Reader, error) { + r, err := scanReader(s.db.QueryRow(s.db.Q(`SELECT `+userCols+` FROM users WHERE id = ?`), id)) + if errors.Is(err, sql.ErrNoRows) { + return model.Reader{}, ErrNotFound + } + return r, err +} + +// CreateStaff 站主创建内容管理员:provider=admin、role=admin。 +// 用户名占用返回 ErrConflict。 +func (s *Store) CreateStaff(handle, passwordHash string) (model.Reader, error) { + if _, err := s.db.Exec(s.db.Q(`INSERT INTO users (provider,handle,name,avatar_url,url,banned,role,password_hash,created_at) + VALUES ('admin',?,'','','',0,'admin',?,?)`), handle, passwordHash, now()); err != nil { + if strings.Contains(err.Error(), "UNIQUE") || strings.Contains(err.Error(), "duplicate key") { + return model.Reader{}, ErrConflict + } + return model.Reader{}, err + } + return s.GetStaffByHandle(handle) +} + +// SetStaffPassword 改后台账号密码(用户管理重置 / 自己修改)。 +func (s *Store) SetStaffPassword(id int64, hash string) error { + _, err := s.db.Exec(s.db.Q(`UPDATE users SET password_hash = ? WHERE id = ? AND provider = 'admin'`), hash, id) + return err +} + +// EnsureStaffHash 只在行内还没有哈希时写入:首次登录把 env / settings 的 +// 引导凭据自迁移成行内哈希。已有哈希(站主后台改过密码)绝不能被 +// env 后路登录覆盖——这正是它和 SetStaffPassword 的区别。 +func (s *Store) EnsureStaffHash(id int64, hash string) error { + _, err := s.db.Exec(s.db.Q(`UPDATE users SET password_hash = ? + WHERE id = ? AND provider = 'admin' AND (password_hash = '' OR password_hash IS NULL)`), hash, id) + return err +} + +// SetStaffBanned 停用 / 恢复后台账号(禁用后既不能登录,存量会话也会在 +// guard 查库时被拒)。 +func (s *Store) SetStaffBanned(id int64, banned bool) error { + _, err := s.db.Exec(s.db.Q(`UPDATE users SET banned = ? WHERE id = ? AND provider = 'admin' AND role != 'owner'`), b2i(banned), id) + return err +} + +// DeleteStaff 删除后台账号。owner 行不可删(全库唯一锚点)。 +func (s *Store) DeleteStaff(id int64) error { + res, err := s.db.Exec(s.db.Q(`DELETE FROM users WHERE id = ? AND provider = 'admin' AND role != 'owner'`), id) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + // BindIdentity 把 (provider, extern_uid) 绑到某个用户上。 // 外部账号已被别人占用时返回 ErrConflict —— 调用方必须原样拒绝, // 不能「后来者覆盖」,否则任何人都能抢先把别人的 GitHub 账号登记成自己的。 @@ -2056,7 +2159,7 @@ func b2i(b bool) int64 { func scanReader(sc interface{ Scan(...any) error }) (model.Reader, error) { var r model.Reader var banned int64 - err := sc.Scan(&r.ID, &r.Provider, &r.Handle, &r.Name, &r.AvatarURL, &r.URL, &banned, &r.Role, &r.CreatedAt) + err := sc.Scan(&r.ID, &r.Provider, &r.Handle, &r.Name, &r.AvatarURL, &r.URL, &banned, &r.Role, &r.PasswordHash, &r.CreatedAt) r.Banned = banned == 1 return r, err } diff --git a/frontend/src/admin/AccountView.vue b/frontend/src/admin/AccountView.vue index 5fd201f..c9b1a29 100644 --- a/frontend/src/admin/AccountView.vue +++ b/frontend/src/admin/AccountView.vue @@ -18,13 +18,39 @@ const uploading = ref(false) const regBusy = ref(false) const fileInput = ref(null) +// 修改登录密码(owner / admin 都在这里改;密码存在自己的账号行上) +const pw = ref({ old: '', next: '', confirm: '' }) +const pwBusy = ref(false) +async function changePassword() { + if (pw.value.next !== pw.value.confirm) { + toastErr('两次输入的新密码不一致') + return + } + if (pw.value.next.length < 6) { + toastErr('新密码至少 6 位') + return + } + pwBusy.value = true + try { + await adminApi.changePassword({ old_password: pw.value.old, new_password: pw.value.next }) + toastOk('密码已更新,下次登录用新密码') + pw.value = { old: '', next: '', confirm: '' } + } catch (e) { + toastErr(e.message || '修改失败') + } finally { + pwBusy.value = false + } +} + const route = useRoute() const router = useRouter() async function load() { + console.warn('ACCT: load() start') loading.value = true try { acct.value = await adminApi.account() + console.warn('ACCT: loaded', !!acct.value) } catch (e) { toastErr(e.message || '读取账户信息失败') } finally { @@ -185,8 +211,8 @@ onMounted(async () => {

- 登录用户名在「设置 → 登录与存储」里改;这里是站主在评论区的 - 身份标识(handle),改它要连登录名一起换。 + 后台账号的用户名即登录名:站主的在「设置 → 登录与存储」里改, + 管理员账号由站主在「用户」页创建。

@@ -195,6 +221,26 @@ onMounted(async () => { + +
+

登录密码

+
+ + +
+
+ + +
+
+ + +
+ +
+

登录方式

diff --git a/frontend/src/admin/AdminLayout.vue b/frontend/src/admin/AdminLayout.vue index ccdcea9..4de5801 100644 --- a/frontend/src/admin/AdminLayout.vue +++ b/frontend/src/admin/AdminLayout.vue @@ -63,6 +63,7 @@ const crumb = computed(() => { if (/^\/admin\/\d+/.test(p)) return '编辑文章' if (p.endsWith('/files')) return '文件' if (p.endsWith('/comments')) return '评论' + if (p.endsWith('/users')) return '用户' if (p.endsWith('/tags')) return '标签' if (p.endsWith('/settings')) return '设置' if (p === '/admin' || p === '/admin/') return '总览' @@ -150,6 +151,11 @@ watch(() => route.path, () => { ◉ 账户 + + + ◍ + 用户 +
前台
↗ diff --git a/frontend/src/admin/LoginView.vue b/frontend/src/admin/LoginView.vue index 51c96d0..6fd2322 100644 --- a/frontend/src/admin/LoginView.vue +++ b/frontend/src/admin/LoginView.vue @@ -18,6 +18,7 @@ async function submit() { try { const data = await adminApi.login(username.value, password.value) session.user = username.value + session.role = data.role || 'admin' void data toastOk('登录成功') router.push('/admin') diff --git a/frontend/src/admin/SettingsView.vue b/frontend/src/admin/SettingsView.vue index db0eb6d..aa15afd 100644 --- a/frontend/src/admin/SettingsView.vue +++ b/frontend/src/admin/SettingsView.vue @@ -1,7 +1,7 @@ + + + + diff --git a/frontend/src/admin/auth.js b/frontend/src/admin/auth.js index 9ff25dc..bf1786f 100644 --- a/frontend/src/admin/auth.js +++ b/frontend/src/admin/auth.js @@ -1,9 +1,12 @@ import { adminApi, session } from '../api' -// 后台的登录态在服务端(httpOnly cookie);这里只是探测一次是否还有效 +// 后台的登录态在服务端(httpOnly cookie);这里探测一次是否还有效, +// 顺带把用户名与角色(owner / admin)存下来,供导航显隐用 export async function checkAuth() { try { - await adminApi.me() + const me = await adminApi.me() + session.user = me.user || '' + session.role = me.role || 'admin' return true } catch (e) { session.clear() diff --git a/frontend/src/api.js b/frontend/src/api.js index 040a736..0795268 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -83,6 +83,11 @@ export const adminApi = { deleteProject: (id) => request('/api/admin/projects/' + id, { method: 'DELETE' }), settings: () => request('/api/admin/settings'), saveSettings: (body) => request('/api/admin/settings', { method: 'PUT', body }), + users: () => request('/api/admin/users'), + createUser: (body) => request('/api/admin/users', { method: 'POST', body }), + patchUser: (id, body) => request('/api/admin/users/' + id, { method: 'PATCH', body }), + deleteUser: (id) => request('/api/admin/users/' + id, { method: 'DELETE' }), + changePassword: (body) => request('/api/admin/account/password', { method: 'PATCH', body }), // ---------- 账户(资料 / 身份绑定 / passkey) ---------- account: () => request('/api/admin/account'), saveAccount: (body) => request('/api/admin/account', { method: 'PATCH', body }), @@ -186,8 +191,10 @@ export const readerApi = { remove: (id) => request(`/api/comments/${id}`, { method: 'DELETE' }) } -// 后台登录态:cookie 由服务端下发(httpOnly),这里只存一份展示用的用户名 +// 后台登录态:cookie 由服务端下发(httpOnly),这里只存展示用的用户名与角色 +// (owner / admin,来自 /api/admin/me,控制「用户管理」等入口的显隐) const USER_KEY = 'one.admin.user' +const ROLE_KEY = 'one.admin.role' export const session = { get user() { @@ -197,7 +204,18 @@ export const session = { if (v) localStorage.setItem(USER_KEY, v) else localStorage.removeItem(USER_KEY) }, + get role() { + return localStorage.getItem(ROLE_KEY) || 'admin' + }, + set role(v) { + if (v) localStorage.setItem(ROLE_KEY, v) + else localStorage.removeItem(ROLE_KEY) + }, + get isOwner() { + return this.role === 'owner' + }, clear() { localStorage.removeItem(USER_KEY) + localStorage.removeItem(ROLE_KEY) } } diff --git a/frontend/src/router.js b/frontend/src/router.js index ed148ea..04b08da 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -23,7 +23,8 @@ const routes = [ { path: 'tags', name: 'admin-tags', component: () => import('./admin/TagsView.vue') }, { path: 'projects', name: 'admin-projects', component: () => import('./admin/ProjectsView.vue') }, { path: 'settings', name: 'admin-settings', component: () => import('./admin/SettingsView.vue') }, - { path: 'account', name: 'admin-account', component: () => import('./admin/AccountView.vue') } + { path: 'account', name: 'admin-account', component: () => import('./admin/AccountView.vue') }, + { path: 'users', name: 'admin-users', component: () => import('./admin/UsersView.vue') } ] },