From 95fa4fd6fda264994e661c5c4d378e8e78122923 Mon Sep 17 00:00:00 2001 From: subtrack Date: Mon, 19 Oct 2026 00:21:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(v1.4):=20=E5=88=9D=E5=A7=8B=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=91=98=EF=BC=88id=3D1=EF=BC=89=E5=8F=AF=E4=BF=AE?= =?UTF-8?q?=E6=94=B9=E5=85=B6=E4=BB=96=E7=AE=A1=E7=90=86=E5=91=98=E7=9A=84?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=90=8D=E3=80=81=E6=98=B5=E7=A7=B0=E5=92=8C?= =?UTF-8?q?=E5=AF=86=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 设置 → 管理员:仅 id=1 的账户在“其他人”那一行看到「编辑」入口,自己那行不显示 - 新增编辑页 /settings/users/{id}/edit:可改用户名、显示名称,密码留空则不修改 - 重置密码后立即清除该账户的所有登录会话,强制对方用新密码重新登录;只改昵称/用户名不影响会话 - 服务端三重校验:非 id=1 拒绝、不能编辑自己、用户名 3-32 字符且唯一、密码 ≥6 位且两次一致 - 所有修改写入操作日志(旧用户名/昵称 → 新值,是否重置密码) - 设置页根据当前账户显示不同提示:超管看到可编辑说明,其他人只看到“只有初始管理员可以修改” 数据库:无需变更(复用 users 表与 sessions 表) 测试: - 新增单测 TestUpdateUserByAdmin:昵称更新、会话保留、改密后旧会话失效、新密码可登录旧密码失效、重名返回 ErrDuplicate、空用户名拒绝 - 功能验证 20 项:入口显示/隐藏、普通管理员与自我编辑被拒、改密踢下线、新旧密码、长度/一致性/重名校验、留空密码、日志记录 - 原有 119 项验收回归全绿;go vet / go test ./... 通过;版本号 1.4.0 --- README.md | 6 ++ internal/store/store_test.go | 62 ++++++++++++++ internal/store/users.go | 40 +++++++++ internal/web/handlers_misc.go | 90 +++++++++++++++++++++ internal/web/server.go | 2 + internal/web/templates/pages/settings.html | 4 + internal/web/templates/pages/user_form.html | 39 +++++++++ main.go | 2 +- 8 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 internal/web/templates/pages/user_form.html diff --git a/README.md b/README.md index 3e6e23d..93aa7c5 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,12 @@ daili.ini 首次运行自动生成 ## 9. 安全说明 +- **初始管理员**(第一个创建的账号,`id=1`)在 `设置 → 管理员` 每行可以点「编辑」,修改其他管理员的用户名、昵称,或重置密码; +- 重置密码会立刻清除对方的登录会话,他需要用新密码重新登录;昵称/用户名的修改不影响会话; +- 自己那一行不显示编辑(改自己的密码用「修改我的密码」),避免误操作把自己锁在门外; +- 其余管理员权限相同,但只能改自己的密码。 + + - 口令使用 PBKDF2-HMAC-SHA256(12 万次迭代 + 随机盐)存储,不可逆; - 所有写操作记录操作人、时间、IP 与摘要,可在「操作日志」里查询; - 服务端模板全部走 `html/template` 自动转义; diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 864e657..bb58a78 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -2,6 +2,7 @@ package store import ( "database/sql" + "errors" "os" "path/filepath" "strconv" @@ -957,3 +958,64 @@ func TestUserColorAndAssignee(t *testing.T) { } } } + +func TestUpdateUserByAdmin(t *testing.T) { + db := newTestDB(t) + + admin, err := db.CreateUser("admin", "老板", "daili123") + if err != nil { + t.Fatal(err) + } + u, err := db.CreateUser("dashou", "阿强", "dashou123") + if err != nil { + t.Fatal(err) + } + + // 建一个会话,稍后用密码重置把它踢掉 + token := "tok-" + domain.Date(domain.Today()) + if err := db.CreateSession(token, u.ID, time.Hour, "127.0.0.1"); err != nil { + t.Fatal(err) + } + if _, err := db.SessionUser(token); err != nil { + t.Fatalf("会话应有效: %v", err) + } + + // 只改昵称:会话保留 + if err := db.UpdateUserByAdmin(u.ID, "dashou", "强哥", ""); err != nil { + t.Fatal(err) + } + got, _ := db.UserByID(u.ID) + if got.DisplayName != "强哥" || got.Username != "dashou" { + t.Fatalf("昵称未更新: %+v", got) + } + if _, err := db.SessionUser(token); err != nil { + t.Fatalf("未改密码不应踢下线: %v", err) + } + + // 改用户名 + 重置密码:会话失效,新密码可登录 + if err := db.UpdateUserByAdmin(u.ID, "laoban", "强哥", "newpass123"); err != nil { + t.Fatal(err) + } + if _, err := db.SessionUser(token); err == nil { + t.Fatal("改密后旧会话应失效") + } + if _, err := db.Authenticate("laoban", "newpass123"); err != nil { + t.Fatalf("新密码应可登录: %v", err) + } + if _, err := db.Authenticate("laoban", "dashou123"); err == nil { + t.Fatal("旧密码不应还能登录") + } + + // 用户名冲突 + if err := db.UpdateUserByAdmin(u.ID, "admin", "", ""); !errors.Is(err, ErrDuplicate) { + t.Fatalf("重名应返回 ErrDuplicate, got %v", err) + } + // 空用户名 + if err := db.UpdateUserByAdmin(u.ID, " ", "", ""); err == nil { + t.Fatal("空用户名应报错") + } + // 超管自己也在表里,但接口层才限制“只能改别人” + if admin.ID != 1 { + t.Fatalf("首个账户 id 应为 1,实际 %d", admin.ID) + } +} diff --git a/internal/store/users.go b/internal/store/users.go index 56453ac..307f7cd 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -148,6 +148,46 @@ func (db *DB) ActiveUsers() ([]User, error) { return out, rows.Err() } +// DeleteUserSessions 清除某个账户的全部登录会话。 +func (db *DB) DeleteUserSessions(userID int64) error { + _, err := db.sql.Exec(`DELETE FROM sessions WHERE user_id=?`, userID) + return err +} + +// UpdateUserByAdmin 由超管修改用户名/昵称,password 非空时同时重置密码。 +// 重置密码会强制该账户下线。 +func (db *DB) UpdateUserByAdmin(userID int64, username, displayName, password string) error { + username = strings.TrimSpace(username) + displayName = strings.TrimSpace(displayName) + if username == "" { + return errors.New("用户名不能为空") + } + hash := "" + if password != "" { + var err error + hash, err = pw.Hash(password) + if err != nil { + return err + } + } + if _, err := db.sql.Exec(`UPDATE users SET username=?, display_name=? WHERE id=?`, + username, displayName, userID); err != nil { + if isUnique(err) { + return ErrDuplicate + } + return err + } + if hash != "" { + if _, err := db.sql.Exec(`UPDATE users SET password_hash=? WHERE id=?`, hash, userID); err != nil { + return err + } + if err := db.DeleteUserSessions(userID); err != nil { + return err + } + } + return nil +} + // Authenticate 校验用户名口令。 func (db *DB) Authenticate(username, password string) (*User, error) { var hash string diff --git a/internal/web/handlers_misc.go b/internal/web/handlers_misc.go index 271b700..d5cfc2d 100644 --- a/internal/web/handlers_misc.go +++ b/internal/web/handlers_misc.go @@ -2,6 +2,7 @@ package web import ( "encoding/csv" + "errors" "fmt" "net" "net/http" @@ -152,6 +153,95 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { s.render(w, r, "settings", p) } +type userFormData struct { + Target *store.User +} + +// isSuperAdmin 只有一个超级管理员(id=1)可以修改他人资料。 +func isSuperAdmin(u *store.User) bool { return u != nil && u.ID == 1 } + +// handleUserEditPage 超管编辑其他管理员。 +func (s *Server) handleUserEditPage(w http.ResponseWriter, r *http.Request) { + me := s.currentUser(r) + if !isSuperAdmin(me) { + s.redirect(w, r, "/settings", "只有初始管理员可以修改其他人的资料") + return + } + id := pathID(r) + if id == me.ID { + s.redirect(w, r, "/settings", "自己的资料请用「修改我的密码」") + return + } + u, err := s.db.UserByID(id) + if err != nil { + s.redirect(w, r, "/settings", "账户不存在") + return + } + p := s.newPage(r, "编辑管理员", "settings") + p.D = &userFormData{Target: u} + s.render(w, r, "user_form", p) +} + +// handleUserEditSave 保存超管对其他管理员的修改。 +func (s *Server) handleUserEditSave(w http.ResponseWriter, r *http.Request) { + me := s.currentUser(r) + if !isSuperAdmin(me) { + s.redirect(w, r, "/settings", "只有初始管理员可以修改其他人的资料") + return + } + id := pathID(r) + if id == me.ID { + s.redirect(w, r, "/settings", "自己的资料请用「修改我的密码」") + return + } + u, err := s.db.UserByID(id) + if err != nil { + s.redirect(w, r, "/settings", "账户不存在") + return + } + username := strings.TrimSpace(r.PostFormValue("username")) + displayName := strings.TrimSpace(r.PostFormValue("display_name")) + password := r.PostFormValue("password") + password2 := r.PostFormValue("password2") + + back := "/settings/users/" + itoa(id) + "/edit" + if l := len([]rune(username)); l < 3 || l > 32 { + s.redirect(w, r, back, "用户名长度需在 3-32 个字符之间") + return + } + if len([]rune(displayName)) > 32 { + s.redirect(w, r, back, "显示名称不能超过 32 个字符") + return + } + if password != "" { + if len([]rune(password)) < 6 { + s.redirect(w, r, back, "新密码至少 6 位") + return + } + if password != password2 { + s.redirect(w, r, back, "两次输入的新密码不一致") + return + } + } + if err := s.db.UpdateUserByAdmin(id, username, displayName, password); err != nil { + msg := "保存失败:" + err.Error() + if errors.Is(err, store.ErrDuplicate) { + msg = "用户名已被占用" + } + s.redirect(w, r, back, msg) + return + } + detail := u.Username + " → " + username + if displayName != u.DisplayName { + detail += ";昵称 " + u.DisplayName + " → " + displayName + } + if password != "" { + detail += ";已重置密码并强制下线" + } + s.logOp(r, "修改管理员资料", "user", id, detail) + s.redirect(w, r, "/settings", "已更新「"+username+"」的资料") +} + // handleUserColor 设置管理员(打手)颜色。 func (s *Server) handleUserColor(w http.ResponseWriter, r *http.Request) { id := pathID(r) diff --git a/internal/web/server.go b/internal/web/server.go index 6820fcc..ecbec7a 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -138,6 +138,8 @@ func (s *Server) routes() http.Handler { mux.HandleFunc("POST /settings/users", s.auth(s.handleUserCreate)) mux.HandleFunc("POST /settings/users/{id}/toggle", s.auth(s.handleUserToggle)) mux.HandleFunc("POST /settings/users/{id}/color", s.auth(s.handleUserColor)) + mux.HandleFunc("GET /settings/users/{id}/edit", s.auth(s.handleUserEditPage)) + mux.HandleFunc("POST /settings/users/{id}/edit", s.auth(s.handleUserEditSave)) mux.HandleFunc("POST /settings/backup", s.auth(s.handleBackupNow)) mux.HandleFunc("GET /export/{kind}", s.auth(s.handleExport)) diff --git a/internal/web/templates/pages/settings.html b/internal/web/templates/pages/settings.html index d6f40b1..5beae79 100644 --- a/internal/web/templates/pages/settings.html +++ b/internal/web/templates/pages/settings.html @@ -26,6 +26,7 @@

管理员

所有账户权限相同,任何操作都会记在日志里。每个管理员的颜色用于日历左侧竖条和待办标识。

+

{{if eq .User.ID 1}}你是初始管理员,可以点「编辑」修改其他管理员的用户名、昵称和密码。{{else}}只有初始管理员(首个账号)可以修改其他管理员的资料。{{end}}

@@ -48,6 +49,9 @@ {{if eq .ID $me.ID}} 当前账户 {{else}} + {{if eq $me.ID 1}} + 编辑 + {{end}} diff --git a/internal/web/templates/pages/user_form.html b/internal/web/templates/pages/user_form.html new file mode 100644 index 0000000..bd7d1c9 --- /dev/null +++ b/internal/web/templates/pages/user_form.html @@ -0,0 +1,39 @@ +{{define "content"}} +
+
+
+

编辑管理员

+

修改 {{.D.Target.Username}} 的登录名、昵称,或重置密码。

+
+ +
+ +
+
+ + +
+ +
+ + +
+

重置密码后该账户的登录会话会立即失效,需要用新密码重新登录。当前账户:{{.User.Username}}

+ +
+ + 取消 +
+ +
+{{end}} diff --git a/main.go b/main.go index 5ea0d1f..908d7af 100644 --- a/main.go +++ b/main.go @@ -26,7 +26,7 @@ import ( "daili/internal/web" ) -const version = "1.3.0" +const version = "1.4.0" func main() { cfgFlag := flag.String("config", "", "配置文件路径(默认:exe 同级的 "+config.FileName+")")
用户名显示名称打手颜色状态创建时间最近登录