多用户与角色: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:
1 parent
e5dee4daf1
commit
4bb2ff4145
23 files changed
+917
-222
No files matched your search
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user