From 008bb876203ec5f3dc9b90c3fb7235ea891bdbd0 Mon Sep 17 00:00:00 2001 From: chengma Date: Sun, 9 Aug 2026 16:49:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E8=87=AA=E5=8A=A9=E4=BF=AE=E6=94=B9=E5=AF=86=E7=A0=81?= =?UTF-8?q?=20(#57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/handler/web/auth.go | 33 +++++ admin/handler/web/auth_test.go | 10 ++ admin/handler/web/web.go | 30 ++++- admin/password_change_integration_test.go | 155 ++++++++++++++++++++++ admin/repository/user.go | 44 +++++- admin/service/auth.go | 40 +++++- admin/service/auth_test.go | 68 ++++++++++ admin/static/css/app.css | 5 + admin/templates/partials/header.html | 46 +++++++ admin/user_integration_test.go | 4 +- docs/admin/05-ui-specification.md | 21 +++ docs/admin/06-quality-security.md | 9 +- 12 files changed, 446 insertions(+), 19 deletions(-) create mode 100644 admin/password_change_integration_test.go diff --git a/admin/handler/web/auth.go b/admin/handler/web/auth.go index 8bf828a..27ad0a9 100644 --- a/admin/handler/web/auth.go +++ b/admin/handler/web/auth.go @@ -113,6 +113,39 @@ func (h *Handler) Logout(c *gin.Context) { c.Redirect(http.StatusSeeOther, "/login?msg="+url.QueryEscape("已退出登录")) } +// ChangePassword 修改当前管理员自己的密码;目标身份只取 Session,不读表单用户编号。 +func (h *Handler) ChangePassword(c *gin.Context) { + returnPath := passwordReturnPath(c.PostForm("next")) + err := service.ChangeAdminPassword(h.db, currentUser(c), c.PostForm("current_password"), + c.PostForm("new_password"), c.PostForm("password_confirm"), time.Now()) + if service.IsValidationError(err) { + query := url.Values{ + "change_password": {"1"}, + "password_error": {err.Error()}, + } + if field := service.ValidationField(err); field != "" { + query.Set("password_field", field) + } + separator := "?" + if strings.Contains(returnPath, "?") { + separator = "&" + } + c.Redirect(http.StatusSeeOther, returnPath+separator+query.Encode()) + return + } + if errors.Is(err, repository.ErrUserPasswordChanged) { + clearAuthCookie(c) + c.Redirect(http.StatusSeeOther, "/login?msg="+url.QueryEscape("密码已在其他操作中变更,请重新登录")) + return + } + if err != nil { + fail(c, http.StatusInternalServerError, "修改密码失败,原密码和现有登录保持不变。刷新后重试。") + return + } + clearAuthCookie(c) + c.Redirect(http.StatusSeeOther, "/login?msg="+url.QueryEscape("密码修改成功,请使用新密码重新登录")) +} + // AuthRequired 只挂在 Web 业务路由组。Client API 注册在另一个组,不能经过这里。 func AuthRequired(db *sql.DB) gin.HandlerFunc { return func(c *gin.Context) { diff --git a/admin/handler/web/auth_test.go b/admin/handler/web/auth_test.go index 4ec353b..bf62cd9 100644 --- a/admin/handler/web/auth_test.go +++ b/admin/handler/web/auth_test.go @@ -27,6 +27,16 @@ func TestSafeNext只允许本站绝对路径(t *testing.T) { } } +func TestPasswordReturnPath移除改密反馈参数(t *testing.T) { + got := passwordReturnPath("/pdd?q=shoe&change_password=1&password_error=bad&password_field=new_password") + if got != "/pdd?q=shoe" { + t.Fatalf("passwordReturnPath = %q,期望保留业务筛选并移除改密参数", got) + } + if got := passwordReturnPath("https://example.com"); got != "/shopee" { + t.Fatalf("外部返回地址应回退到 /shopee,实际 %q", got) + } +} + func TestAuthCookie安全属性(t *testing.T) { gin.SetMode(gin.TestMode) for _, test := range []struct { diff --git a/admin/handler/web/web.go b/admin/handler/web/web.go index 7144596..9850c85 100644 --- a/admin/handler/web/web.go +++ b/admin/handler/web/web.go @@ -12,6 +12,7 @@ package web import ( "database/sql" "net/http" + "net/url" "time" "github.com/gin-gonic/gin" @@ -44,6 +45,8 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) { // 登录中间件只挂业务网页组,绝不能挂在整个 Engine。 pages := r.Group("/", CSRFMiddleware(), AuthRequired(db)) pages.POST("/logout", h.Logout) + account := pages.Group("/account", AdminRequired()) + account.POST("/change-password", h.ChangePassword) // 打开根路径直接进第一个模块 pages.GET("/", func(c *gin.Context) { @@ -97,11 +100,16 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) { // page 组装每个页面都要的公共数据(导航高亮、标题、CSRF token)。 func page(c *gin.Context, active, title string, extra gin.H) gin.H { + actor := currentUser(c) data := gin.H{ - "Active": active, - "Title": title, - "CSRFToken": csrfToken(c), - "CurrentUser": currentUser(c), + "Active": active, + "Title": title, + "CSRFToken": csrfToken(c), + "CurrentUser": actor, + "PasswordChangeOpen": actor != nil && actor.IsAdmin() && c.Query("change_password") == "1", + "PasswordChangeError": c.Query("password_error"), + "PasswordChangeField": c.Query("password_field"), + "PasswordReturnPath": passwordReturnPath(c.Request.URL.RequestURI()), } for k, v := range extra { data[k] = v @@ -109,6 +117,20 @@ func page(c *gin.Context, active, title string, extra gin.H) gin.H { return data } +// passwordReturnPath 保留用户所在业务页和原筛选条件,但移除改密弹窗自己的反馈参数。 +func passwordReturnPath(raw string) string { + parsed, err := url.Parse(safeNext(raw)) + if err != nil { + return "/shopee" + } + query := parsed.Query() + query.Del("change_password") + query.Del("password_error") + query.Del("password_field") + parsed.RawQuery = query.Encode() + return parsed.RequestURI() +} + // fail 渲染一个错误页。 // // 错误信息要说清三件事:发生了什么、保住了什么、下一步做什么。 diff --git a/admin/password_change_integration_test.go b/admin/password_change_integration_test.go new file mode 100644 index 0000000..791d53d --- /dev/null +++ b/admin/password_change_integration_test.go @@ -0,0 +1,155 @@ +package main + +import ( + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "cmautobuy/admin/repository" + "cmautobuy/admin/service" +) + +func Test管理员自助修改密码完整流程和权限边界(t *testing.T) { + db, err := repository.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if err := repository.Migrate(db); err != nil { + t.Fatal(err) + } + now := time.Now() + if err := service.SetupInitialAdmin(db, "admin", "old-password", "old-password", now); err != nil { + t.Fatal(err) + } + admin, _ := repository.FindUserByUsername(db, "admin") + if err := service.CreatePurchaser(db, admin, "buyer", "buyer-password", "buyer-password", now); err != nil { + t.Fatal(err) + } + router, err := newRouter(db) + if err != nil { + t.Fatal(err) + } + + adminTokenA, _, _, _ := service.Login(db, "admin", "old-password", now) + adminTokenB, _, _, _ := service.Login(db, "admin", "old-password", now) + buyerToken, _, _, _ := service.Login(db, "buyer", "buyer-password", now) + + adminPageRequest := httptest.NewRequest(http.MethodGet, "/pdd?q=shoe", nil) + adminPageRequest.AddCookie(&http.Cookie{Name: "cmautobuy_session", Value: adminTokenA}) + adminPage := httptest.NewRecorder() + router.ServeHTTP(adminPage, adminPageRequest) + if adminPage.Code != http.StatusOK { + t.Fatalf("管理员页面 = %d", adminPage.Code) + } + adminHTML := adminPage.Body.String() + for _, want := range []string{"修改密码", `action="/account/change-password"`, `autocomplete="current-password"`, `autocomplete="new-password"`, `value="/pdd?q=shoe"`} { + if !strings.Contains(adminHTML, want) { + t.Errorf("管理员页面缺少 %q", want) + } + } + for _, secret := range []string{"old-password", "buyer-password"} { + if strings.Contains(adminHTML, secret) { + t.Fatalf("页面泄露密码 %q", secret) + } + } + csrf := findResponseCookie(t, adminPage, "cmautobuy_csrf") + + buyerPageRequest := httptest.NewRequest(http.MethodGet, "/pdd", nil) + buyerPageRequest.AddCookie(&http.Cookie{Name: "cmautobuy_session", Value: buyerToken}) + buyerPage := httptest.NewRecorder() + router.ServeHTTP(buyerPage, buyerPageRequest) + if strings.Contains(buyerPage.Body.String(), `action="/account/change-password"`) || strings.Contains(buyerPage.Body.String(), ">修改密码") { + t.Fatal("采购员页面不应显示修改密码入口或表单") + } + + wrong := postFormRequest("/account/change-password", url.Values{ + "csrf_token": {csrf.Value}, "current_password": {"wrong-password"}, + "new_password": {"fresh-secret-789"}, "password_confirm": {"fresh-secret-789"}, + "next": {"/pdd?q=shoe"}, + }, csrf) + wrong.AddCookie(&http.Cookie{Name: "cmautobuy_session", Value: adminTokenA}) + wrongResponse := httptest.NewRecorder() + router.ServeHTTP(wrongResponse, wrong) + location := wrongResponse.Header().Get("Location") + if wrongResponse.Code != http.StatusSeeOther || !strings.HasPrefix(location, "/pdd?") || + !strings.Contains(location, "change_password=1") || strings.Contains(location, "wrong-password") || strings.Contains(location, "fresh-secret-789") { + t.Fatalf("错误当前密码重定向 = %d %q", wrongResponse.Code, location) + } + if _, err := service.Authenticate(db, adminTokenA, now.Add(time.Minute)); err != nil { + t.Fatalf("失败改密后 Session 应保持有效: %v", err) + } + + errorPageRequest := httptest.NewRequest(http.MethodGet, location, nil) + errorPageRequest.AddCookie(&http.Cookie{Name: "cmautobuy_session", Value: adminTokenA}) + errorPage := httptest.NewRecorder() + router.ServeHTTP(errorPage, errorPageRequest) + errorHTML := errorPage.Body.String() + if !strings.Contains(errorHTML, "当前密码错误,请重新输入") || + !strings.Contains(errorHTML, `id="change-password-modal" >`) || + !strings.Contains(errorHTML, `name="current_password" required`) || + !strings.Contains(errorHTML, "autofocus") { + t.Fatalf("校验失败后弹窗、错误或焦点不正确: %s", errorHTML) + } + for _, secret := range []string{"wrong-password", "fresh-secret-789"} { + if strings.Contains(errorHTML, secret) { + t.Fatalf("失败页面泄露提交密码 %q", secret) + } + } + + noCSRF := postFormRequest("/account/change-password", url.Values{ + "current_password": {"old-password"}, "new_password": {"fresh-secret-789"}, + "password_confirm": {"fresh-secret-789"}, + }) + noCSRF.AddCookie(&http.Cookie{Name: "cmautobuy_session", Value: adminTokenA}) + noCSRFResponse := httptest.NewRecorder() + router.ServeHTTP(noCSRFResponse, noCSRF) + if noCSRFResponse.Code != http.StatusForbidden { + t.Fatalf("无 CSRF 改密 = %d,期望 403", noCSRFResponse.Code) + } + + buyerPost := postFormRequest("/account/change-password", url.Values{ + "csrf_token": {csrf.Value}, "current_password": {"buyer-password"}, + "new_password": {"fresh-secret-789"}, "password_confirm": {"fresh-secret-789"}, + }, csrf) + buyerPost.AddCookie(&http.Cookie{Name: "cmautobuy_session", Value: buyerToken}) + buyerResponse := httptest.NewRecorder() + router.ServeHTTP(buyerResponse, buyerPost) + if buyerResponse.Code != http.StatusForbidden || !strings.Contains(buyerResponse.Body.String(), "只有管理员") { + t.Fatalf("采购员直接改密 = %d %s", buyerResponse.Code, buyerResponse.Body.String()) + } + + success := postFormRequest("/account/change-password", url.Values{ + "csrf_token": {csrf.Value}, "current_password": {"old-password"}, + "new_password": {"fresh-secret-789"}, "password_confirm": {"fresh-secret-789"}, + "next": {"/pdd?q=shoe"}, "user_id": {"buyer"}, + }, csrf) + success.AddCookie(&http.Cookie{Name: "cmautobuy_session", Value: adminTokenA}) + successResponse := httptest.NewRecorder() + router.ServeHTTP(successResponse, success) + if successResponse.Code != http.StatusSeeOther || !strings.HasPrefix(successResponse.Header().Get("Location"), "/login?msg=") { + t.Fatalf("成功改密 = %d %q", successResponse.Code, successResponse.Header().Get("Location")) + } + cleared := findResponseCookie(t, successResponse, "cmautobuy_session") + if cleared.Value != "" || cleared.MaxAge != -1 { + t.Fatalf("成功后未清除认证 Cookie: %#v", cleared) + } + for _, token := range []string{adminTokenA, adminTokenB} { + if _, err := service.Authenticate(db, token, now.Add(time.Minute)); !errors.Is(err, service.ErrUnauthenticated) { + t.Fatalf("管理员全部旧 Session 应失效,实际 %v", err) + } + } + if _, _, _, err := service.Login(db, "admin", "old-password", now.Add(time.Minute)); !errors.Is(err, service.ErrInvalidCredentials) { + t.Fatalf("旧密码仍可登录: %v", err) + } + if _, _, _, err := service.Login(db, "admin", "fresh-secret-789", now.Add(time.Minute)); err != nil { + t.Fatalf("新密码不能登录: %v", err) + } + if _, _, _, err := service.Login(db, "buyer", "buyer-password", now.Add(time.Minute)); err != nil { + t.Fatalf("伪造 user_id 不应修改其他用户密码: %v", err) + } +} diff --git a/admin/repository/user.go b/admin/repository/user.go index 855d53a..167a5d8 100644 --- a/admin/repository/user.go +++ b/admin/repository/user.go @@ -12,11 +12,12 @@ import ( var ( // ErrUsersAlreadyExist 表示首次初始化入口已经永久关闭。 - ErrUsersAlreadyExist = errors.New("管理员已经初始化") - ErrUserNotFound = errors.New("用户不存在") - ErrSessionNotFound = errors.New("网页登录会话不存在") - ErrUsernameExists = errors.New("用户名已经存在") - ErrLastActiveAdmin = errors.New("不能禁用最后一个有效管理员") + ErrUsersAlreadyExist = errors.New("管理员已经初始化") + ErrUserNotFound = errors.New("用户不存在") + ErrSessionNotFound = errors.New("网页登录会话不存在") + ErrUsernameExists = errors.New("用户名已经存在") + ErrLastActiveAdmin = errors.New("不能禁用最后一个有效管理员") + ErrUserPasswordChanged = errors.New("用户密码已经变更") ) // CountUsers 返回所有用户数量,disabled 也算。即使所有账号被禁用, @@ -29,6 +30,39 @@ func CountUsers(q Execer) (int, error) { return count, nil } +// ChangePasswordAndRevokeSessions 只在旧哈希仍匹配时更新密码,并在同一事务中 +// 撤销该用户的全部 Session。旧哈希条件用于阻止两个并发改密请求互相覆盖。 +func ChangePasswordAndRevokeSessions(db *sql.DB, userID, expectedHash, passwordHash, updatedAt string) error { + tx, err := db.Begin() + if err != nil { + return fmt.Errorf("开始修改密码事务失败: %w", err) + } + defer tx.Rollback() + + result, err := tx.Exec(` + UPDATE users + SET password_hash = ?, password_changed_at = ?, updated_at = ? + WHERE user_id = ? AND password_hash = ? AND role = 'admin' AND status = 'active'`, + passwordHash, updatedAt, updatedAt, userID, expectedHash) + if err != nil { + return fmt.Errorf("修改管理员密码失败: %w", err) + } + affected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("确认修改密码结果失败: %w", err) + } + if affected == 0 { + return ErrUserPasswordChanged + } + if _, err := tx.Exec(`DELETE FROM web_sessions WHERE user_id = ?`, userID); err != nil { + return fmt.Errorf("撤销管理员 Session 失败: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("提交修改密码事务失败: %w", err) + } + return nil +} + // CreateInitialAdmin 在写事务里再次确认 users 为空后创建首位管理员。 // Open 的 _txlock=immediate 让并发事务从一开始就串行,最多一个能成功。 func CreateInitialAdmin(db *sql.DB, user model.User) error { diff --git a/admin/service/auth.go b/admin/service/auth.go index dd1def6..e03e6fa 100644 --- a/admin/service/auth.go +++ b/admin/service/auth.go @@ -32,7 +32,10 @@ var ( dummyPasswordHash, _ = bcrypt.GenerateFromPassword([]byte("not-a-real-password"), bcrypt.DefaultCost) ) -type validationError struct{ message string } +type validationError struct { + field string + message string +} func (e *validationError) Error() string { return e.message } @@ -42,10 +45,23 @@ func IsValidationError(err error) bool { return errors.As(err, &target) } +// ValidationField 返回表单错误对应的字段名,供页面把焦点放到需要修正的位置。 +func ValidationField(err error) string { + var target *validationError + if errors.As(err, &target) { + return target.field + } + return "" +} + func invalidInput(message string, args ...any) error { return &validationError{message: fmt.Sprintf(message, args...)} } +func invalidFieldInput(field, message string, args ...any) error { + return &validationError{field: field, message: fmt.Sprintf(message, args...)} +} + // HasUsers 判断首次初始化入口是否已经永久关闭。 func HasUsers(db *sql.DB) (bool, error) { count, err := repository.CountUsers(db) @@ -86,15 +102,15 @@ func validateUsername(username string) error { func hashConfirmedPassword(password, confirmation string) ([]byte, error) { if len([]rune(password)) < minimumPasswordLen { - return nil, invalidInput("密码至少需要 %d 个字符", minimumPasswordLen) + return nil, invalidFieldInput("new_password", "密码至少需要 %d 个字符", minimumPasswordLen) } // bcrypt 最多接受 72 字节。中文等字符可能占多个字节,因此不能只靠 // HTML 的 maxlength;服务端需要在哈希前给出可理解的校验错误。 if len([]byte(password)) > 72 { - return nil, invalidInput("密码不能超过 72 个字节") + return nil, invalidFieldInput("new_password", "密码不能超过 72 个字节") } if password != confirmation { - return nil, invalidInput("两次输入的密码不一致") + return nil, invalidFieldInput("password_confirm", "两次输入的密码不一致") } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { @@ -103,6 +119,22 @@ func hashConfirmedPassword(password, confirmation string) ([]byte, error) { return hash, nil } +// ChangeAdminPassword 校验当前管理员密码,更新新密码并撤销该账号全部 Session。 +func ChangeAdminPassword(db *sql.DB, actor *model.User, currentPassword, newPassword, confirmation string, now time.Time) error { + if actor == nil || !actor.IsAdmin() { + return ErrAdminRequired + } + if bcrypt.CompareHashAndPassword([]byte(actor.PasswordHash), []byte(currentPassword)) != nil { + return invalidFieldInput("current_password", "当前密码错误,请重新输入") + } + hash, err := hashConfirmedPassword(newPassword, confirmation) + if err != nil { + return err + } + return repository.ChangePasswordAndRevokeSessions( + db, actor.UserID, actor.PasswordHash, string(hash), now.UTC().Format(model.TimeLayout)) +} + // Login 校验统一凭据并创建一个固定 12 小时有效的 Session。 // 返回的 token 原文只交给 Cookie,数据库仅保存 SHA-256。 func Login(db *sql.DB, username, password string, now time.Time) (token string, user *model.User, expiresAt time.Time, err error) { diff --git a/admin/service/auth_test.go b/admin/service/auth_test.go index 0d673c1..389224d 100644 --- a/admin/service/auth_test.go +++ b/admin/service/auth_test.go @@ -169,3 +169,71 @@ func TestAuthenticate_过期Session被删除(t *testing.T) { t.Fatalf("过期 Session 应被清理,实际剩 %d", count) } } + +func TestChangeAdminPassword_校验当前密码并撤销全部Session(t *testing.T) { + db := newSyncTestDB(t) + now := time.Date(2026, 8, 9, 8, 0, 0, 0, time.UTC) + if err := SetupInitialAdmin(db, "admin", "old-password", "old-password", now); err != nil { + t.Fatal(err) + } + tokenA, actor, _, err := Login(db, "admin", "old-password", now) + if err != nil { + t.Fatal(err) + } + tokenB, _, _, err := Login(db, "admin", "old-password", now) + if err != nil { + t.Fatal(err) + } + + err = ChangeAdminPassword(db, actor, "wrong-password", "new-password", "new-password", now.Add(time.Minute)) + if err == nil || ValidationField(err) != "current_password" || !strings.Contains(err.Error(), "当前密码错误") { + t.Fatalf("错误当前密码应定位到 current_password,实际 %v", err) + } + if _, err := Authenticate(db, tokenA, now.Add(2*time.Minute)); err != nil { + t.Fatalf("失败改密不应撤销原 Session: %v", err) + } + + if err := ChangeAdminPassword(db, actor, "old-password", "new-password", "new-password", now.Add(3*time.Minute)); err != nil { + t.Fatalf("修改管理员密码失败: %v", err) + } + for _, token := range []string{tokenA, tokenB} { + if _, err := Authenticate(db, token, now.Add(4*time.Minute)); !errors.Is(err, ErrUnauthenticated) { + t.Fatalf("改密后全部旧 Session 都应失效,实际 %v", err) + } + } + if _, _, _, err := Login(db, "admin", "old-password", now.Add(5*time.Minute)); !errors.Is(err, ErrInvalidCredentials) { + t.Fatalf("改密后旧密码不应登录,实际 %v", err) + } + if _, _, _, err := Login(db, "admin", "new-password", now.Add(5*time.Minute)); err != nil { + t.Fatalf("改密后新密码应可登录: %v", err) + } +} + +func TestChangeAdminPassword_新密码字段校验与权限(t *testing.T) { + db := newSyncTestDB(t) + now := time.Now() + if err := SetupInitialAdmin(db, "admin", "old-password", "old-password", now); err != nil { + t.Fatal(err) + } + admin, _ := repository.FindUserByUsername(db, "admin") + for _, test := range []struct { + name string + password string + confirmation string + field string + }{ + {"少于六个字符", "12345", "12345", "new_password"}, + {"超过七十二字节", strings.Repeat("密", 25), strings.Repeat("密", 25), "new_password"}, + {"确认不一致", "123456", "654321", "password_confirm"}, + } { + t.Run(test.name, func(t *testing.T) { + err := ChangeAdminPassword(db, admin, "old-password", test.password, test.confirmation, now) + if err == nil || ValidationField(err) != test.field { + t.Fatalf("校验错误 = %v,期望字段 %s", err, test.field) + } + }) + } + if err := ChangeAdminPassword(db, nil, "old-password", "123456", "123456", now); !errors.Is(err, ErrAdminRequired) { + t.Fatalf("非管理员应被拒绝,实际 %v", err) + } +} diff --git a/admin/static/css/app.css b/admin/static/css/app.css index 375ec6f..7e1e73d 100644 --- a/admin/static/css/app.css +++ b/admin/static/css/app.css @@ -43,12 +43,15 @@ body { color: #fff; padding: 0 8px; } +.nav-account { margin: 0; } .nav-logout { margin: 0; } +.nav-account, .nav-logout button { color: #c8cdd4; border-color: #56616d; background: transparent; } +.nav-account:hover, .nav-logout button:hover { color: #fff; background: #3a4552; } /* ── 首次初始化 / 登录 ────────────────── */ @@ -294,6 +297,8 @@ button.primary:hover { background: #1a5fd0; } /* 创建弹窗里的单个字段,标签在输入框上面 */ .field { display: flex; flex-direction: column; gap: 6px; } .field label { color: #555; } +.field small { color: #777; } +.field .field-error { color: #b42318; font-weight: 600; } input[type="text"], input[type="password"] { padding: 5px 8px; border: 1px solid #ccd1d6; diff --git a/admin/templates/partials/header.html b/admin/templates/partials/header.html index 734dd65..2ffa7c6 100644 --- a/admin/templates/partials/header.html +++ b/admin/templates/partials/header.html @@ -20,6 +20,9 @@ {{end}} {{if .CurrentUser}} {{.CurrentUser.Username}} + {{if .CurrentUser.IsAdmin}} + + {{end}} +{{end}} +
{{end}} diff --git a/admin/user_integration_test.go b/admin/user_integration_test.go index 1e30ce3..c35210a 100644 --- a/admin/user_integration_test.go +++ b/admin/user_integration_test.go @@ -50,8 +50,8 @@ func Test用户管理管理员与采购员权限边界(t *testing.T) { t.Errorf("用户管理页缺少 %q", want) } } - if strings.Count(adminPage.Body.String(), `minlength="6"`) != 4 || - strings.Count(adminPage.Body.String(), "至少 6 个字符") != 2 { + if strings.Count(adminPage.Body.String(), `minlength="6"`) != 6 || + strings.Count(adminPage.Body.String(), "至少 6 个字符") != 3 { t.Fatalf("创建和重置密码表单没有统一使用 6 个字符规则") } for _, secret := range []string{"admin-password", "buyer-password"} { diff --git a/docs/admin/05-ui-specification.md b/docs/admin/05-ui-specification.md index 88bff9b..3d38d22 100644 --- a/docs/admin/05-ui-specification.md +++ b/docs/admin/05-ui-specification.md @@ -681,6 +681,27 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD - 不能禁用最后一个有效管理员,也不能让唯一管理员禁用自己。 - 完整密码不在列表、弹窗关闭后的页面或日志中出现。 +### 8.3 管理员修改自己的密码 + +已登录管理员的顶部账号区域显示“修改密码”,采购员不显示该入口。点击后打开公共弹窗: + +```text +修改我的密码 +当前密码 [••••••••••••••••] +新密码 [••••••••••••••••] +确认新密码 [••••••••••••••••] +[取消] [修改密码] +``` + +- 必须校验当前密码;目标用户只取当前 Session,不接受表单传入用户编号。 +- 新密码至少 6 个字符、最多 72 个字节,两次输入必须一致。 +- 当前密码使用 `autocomplete="current-password"`,新密码和确认密码使用 + `autocomplete="new-password"`。 +- 校验失败时不回显三个密码,自动重新打开弹窗,在对应字段旁显示错误并聚焦该字段。 +- 修改成功后撤销该管理员在所有浏览器中的 Session、清除当前 Cookie,并跳转登录页要求使用新密码登录。 +- 采购员仍由管理员在用户管理页重置密码;本阶段不提供采购员自助改密和忘记密码入口。 +- 弹窗继续遵守公共关闭规则:取消、关闭按钮、Esc 和直接点击遮罩可关闭,从密码框拖选到遮罩不会误关闭。 + ## 9. 反馈方式 | 场景 | 怎么反馈 | diff --git a/docs/admin/06-quality-security.md b/docs/admin/06-quality-security.md index 9d033b1..5f86642 100644 --- a/docs/admin/06-quality-security.md +++ b/docs/admin/06-quality-security.md @@ -29,7 +29,7 @@ - SKU 映射复用:第二次匹配同一 SKU 应自动带出; - 在线状态派生:`last_seen_at` 刚好在边界前后。 - 密码哈希校验:正确密码成功,错误密码失败,数据库不出现明文密码; -- 密码长度边界:5 个字符拒绝、6 个字符接受;初始化、创建和重置规则一致; +- 密码长度边界:5 个字符拒绝、6 个字符接受;初始化、创建、重置和管理员自助修改规则一致; - 角色校验:管理员可以管理用户,采购员访问用户管理返回 `403`; - 最后管理员保护:不能禁用最后一个有效管理员。 - 客户端归属:一人多客户端、一台客户端唯一当前负责人、转交/解绑历史完整; @@ -107,13 +107,14 @@ - `[建议]` MVP 只监听 `127.0.0.1`,不对外暴露。要给内网用再单独评估。 - `[必须]` 不提供固定默认密码和公开注册;第一位管理员由用户首次初始化。 - `[必须]` 密码使用成熟算法哈希,禁止自创加密、明文保存或可逆加密。 -- `[必须]` 密码最少 6 个字符、最多 72 个字节;初始化、创建采购员、重置密码 +- `[必须]` 密码最少 6 个字符、最多 72 个字节;初始化、创建采购员、重置密码、管理员自助修改 的服务端校验和 HTML 表单约束保持一致。 - `[必须]` 首次管理员创建必须在数据库写事务中完成,并发请求最多一个成功。 - `[必须]` 登录成功后使用新的随机 Session Token,数据库只保存其 SHA-256 哈希。 - `[必须]` 登录 Cookie 设置 `HttpOnly`、`SameSite=Lax`、`Path=/`;HTTPS 部署时设置 `Secure`。 -- `[必须]` Session 默认 12 小时过期;退出、密码重置和账号禁用立即撤销对应 Session。 -- `[必须]` `/setup`、`/login`、`/logout` 和用户管理写操作都保留 CSRF 防护。 +- `[必须]` Session 默认 12 小时过期;退出、密码重置、管理员自助修改密码和账号禁用立即撤销对应 Session。 +- `[必须]` 管理员自助修改密码必须验证当前密码,目标账号从当前 Session 取得;成功后撤销该账号全部 Session 并重新登录。 +- `[必须]` `/setup`、`/login`、`/logout`、`/account/change-password` 和用户管理写操作都保留 CSRF 防护。 - `[必须]` 客户端绑定、转交、解绑和删除只能由管理员执行,并保留 CSRF 防护。 - `[必须]` Web 登录中间件只保护 HTML 路由,不得覆盖 `/api/v1/client/*`。 - `[建议]` 对连续登录失败做简单限速;错误提示不区分用户名不存在和密码错误。