多用户与角色:owner / admin / reader 三级,用户管理页 + 密码上库

- 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)
This commit is contained in:
Sakurasan committed 2026-10-01 22:35:37 +08:00
1 parent e5dee4daf1
commit 4bb2ff4145
23 files changed
+917 -222

No files matched your search

+11 -18
View File
@@ -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,
}
+21 -17
View File
@@ -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)
}
+61
View File
@@ -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
}
+266 -78
View File
@@ -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
+144 -15
View File
@@ -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")
}
}
+10 -10
View File
@@ -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())
}
+22 -16
View File
@@ -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:<id>: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:<id>:<exp>
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 {