多用户与角色: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
+266
-78
@@ -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
|
||||
|
||||
Reference in new issue
Block a user