feat: 支持管理员自助修改密码 (#57)

This commit is contained in:
chengma
2026-08-09 16:49:28 +08:00
parent 2cbb98f09c
commit 008bb87620
12 changed files with 446 additions and 19 deletions
+33
View File
@@ -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) {
+10
View File
@@ -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 {
+26 -4
View File
@@ -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 渲染一个错误页。
//
// 错误信息要说清三件事:发生了什么、保住了什么、下一步做什么。
+155
View File
@@ -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(), ">修改密码</button>") {
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)
}
}
+39 -5
View File
@@ -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 {
+36 -4
View File
@@ -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) {
+68
View File
@@ -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)
}
}
+5
View File
@@ -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;
+46
View File
@@ -20,6 +20,9 @@
{{end}}
{{if .CurrentUser}}
<span class="nav-user">{{.CurrentUser.Username}}</span>
{{if .CurrentUser.IsAdmin}}
<button type="button" class="nav-account" data-modal-open="change-password-modal">修改密码</button>
{{end}}
<form class="nav-logout" method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit">退出登录</button>
@@ -27,5 +30,48 @@
{{end}}
</nav>
{{if and .CurrentUser .CurrentUser.IsAdmin}}
<div class="modal-backdrop" id="change-password-modal" {{if not .PasswordChangeOpen}}hidden{{end}}>
<div class="modal modal-narrow" role="dialog" aria-modal="true" aria-labelledby="change-password-title">
<div class="modal-head">
<h2 id="change-password-title">修改我的密码</h2>
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
</div>
<form method="post" action="/account/change-password">
<div class="modal-body form-stack">
<p class="hint">修改成功后,这个管理员在所有浏览器中的登录都会失效,需要使用新密码重新登录。</p>
<div class="field">
<label for="current-password">当前密码</label>
<input id="current-password" type="password" name="current_password" required
autocomplete="current-password" {{if or (not .PasswordChangeField) (eq .PasswordChangeField "current_password")}}autofocus{{end}}
{{if eq .PasswordChangeField "current_password"}}aria-invalid="true" aria-describedby="current-password-error"{{end}}>
{{if eq .PasswordChangeField "current_password"}}<small id="current-password-error" class="field-error" role="alert">{{.PasswordChangeError}}</small>{{end}}
</div>
<div class="field">
<label for="changed-password">新密码</label>
<input id="changed-password" type="password" name="new_password" minlength="6"
maxlength="72" required autocomplete="new-password" {{if eq .PasswordChangeField "new_password"}}autofocus aria-invalid="true" aria-describedby="changed-password-error"{{end}}>
{{if eq .PasswordChangeField "new_password"}}
<small id="changed-password-error" class="field-error" role="alert">{{.PasswordChangeError}}</small>
{{else}}<small>至少 6 个字符,最多 72 个字节。</small>{{end}}
</div>
<div class="field">
<label for="changed-password-confirm">确认新密码</label>
<input id="changed-password-confirm" type="password" name="password_confirm"
minlength="6" maxlength="72" required autocomplete="new-password" {{if eq .PasswordChangeField "password_confirm"}}autofocus aria-invalid="true" aria-describedby="changed-password-confirm-error"{{end}}>
{{if eq .PasswordChangeField "password_confirm"}}<small id="changed-password-confirm-error" class="field-error" role="alert">{{.PasswordChangeError}}</small>{{end}}
</div>
<input type="hidden" name="next" value="{{.PasswordReturnPath}}">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
</div>
<div class="modal-foot">
<button type="button" data-modal-close>取消</button>
<button type="submit" class="primary">修改密码</button>
</div>
</form>
</div>
</div>
{{end}}
<main class="page">
{{end}}
+2 -2
View File
@@ -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"} {
+21
View File
@@ -681,6 +681,27 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD
- 不能禁用最后一个有效管理员,也不能让唯一管理员禁用自己。
- 完整密码不在列表、弹窗关闭后的页面或日志中出现。
### 8.3 管理员修改自己的密码
已登录管理员的顶部账号区域显示“修改密码”,采购员不显示该入口。点击后打开公共弹窗:
```text
修改我的密码
当前密码 [••••••••••••••••]
新密码 [••••••••••••••••]
确认新密码 [••••••••••••••••]
[取消] [修改密码]
```
- 必须校验当前密码;目标用户只取当前 Session,不接受表单传入用户编号。
- 新密码至少 6 个字符、最多 72 个字节,两次输入必须一致。
- 当前密码使用 `autocomplete="current-password"`,新密码和确认密码使用
`autocomplete="new-password"`。
- 校验失败时不回显三个密码,自动重新打开弹窗,在对应字段旁显示错误并聚焦该字段。
- 修改成功后撤销该管理员在所有浏览器中的 Session、清除当前 Cookie,并跳转登录页要求使用新密码登录。
- 采购员仍由管理员在用户管理页重置密码;本阶段不提供采购员自助改密和忘记密码入口。
- 弹窗继续遵守公共关闭规则:取消、关闭按钮、Esc 和直接点击遮罩可关闭,从密码框拖选到遮罩不会误关闭。
## 9. 反馈方式
| 场景 | 怎么反馈 |
+5 -4
View File
@@ -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/*`。
- `[建议]` 对连续登录失败做简单限速;错误提示不区分用户名不存在和密码错误。