多用户与角色: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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 表示未启用,路由不开放)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user