多用户与角色: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

+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")
}
}