diff --git a/admin/auth_integration_test.go b/admin/auth_integration_test.go index dd5f335..a67db64 100644 --- a/admin/auth_integration_test.go +++ b/admin/auth_integration_test.go @@ -29,6 +29,10 @@ func TestAdmin首次初始化登录退出完整流程(t *testing.T) { if setupPage.Code != http.StatusOK || !strings.Contains(setupPage.Body.String(), "初始化管理员") { t.Fatalf("GET /setup = %d,响应:%s", setupPage.Code, setupPage.Body.String()) } + if strings.Count(setupPage.Body.String(), `minlength="6"`) != 2 || + !strings.Contains(setupPage.Body.String(), "至少 6 个字符") { + t.Fatalf("初始化表单密码规则未同步为 6 个字符:%s", setupPage.Body.String()) + } csrfCookie := findResponseCookie(t, setupPage, "cmautobuy_csrf") if csrfCookie.HttpOnly || csrfCookie.Value == "" { t.Fatalf("CSRF Cookie 应可供双提交表单使用: %#v", csrfCookie) diff --git a/admin/service/auth.go b/admin/service/auth.go index 68d95a4..dd1def6 100644 --- a/admin/service/auth.go +++ b/admin/service/auth.go @@ -20,7 +20,7 @@ import ( const ( WebSessionDuration = 12 * time.Hour - minimumPasswordLen = 8 + minimumPasswordLen = 6 maxUsernameLen = 64 ) diff --git a/admin/service/auth_test.go b/admin/service/auth_test.go index c961088..0d673c1 100644 --- a/admin/service/auth_test.go +++ b/admin/service/auth_test.go @@ -39,7 +39,7 @@ func TestSetupInitialAdmin_校验密码字符数和Bcrypt字节上限(t *testing password string want string }{ - {"少于八个字符", "1234567", "至少需要 8 个字符"}, + {"少于六个字符", "12345", "至少需要 6 个字符"}, {"超过bcrypt字节上限", strings.Repeat("密", 25), "不能超过 72 个字节"}, } { t.Run(test.name, func(t *testing.T) { @@ -52,6 +52,13 @@ func TestSetupInitialAdmin_校验密码字符数和Bcrypt字节上限(t *testing } } +func TestSetupInitialAdmin_六个字符密码可用(t *testing.T) { + db := newSyncTestDB(t) + if err := SetupInitialAdmin(db, "admin", "123456", "123456", time.Now()); err != nil { + t.Fatalf("六个字符应达到最小密码长度,实际 %v", err) + } +} + func TestSetupInitialAdmin_并发最多一个成功(t *testing.T) { db := newSyncTestDB(t) start := make(chan struct{}) diff --git a/admin/service/user_test.go b/admin/service/user_test.go index db90026..91180f6 100644 --- a/admin/service/user_test.go +++ b/admin/service/user_test.go @@ -16,7 +16,7 @@ func TestCreatePurchaser_固定角色且用户名不区分大小写唯一(t *tes db := newSyncTestDB(t) now := time.Date(2026, 8, 9, 8, 0, 0, 0, time.UTC) admin := prepareAdminUser(t, db, now) - if err := CreatePurchaser(db, admin, "buyer", "buyer-password", "buyer-password", now); err != nil { + if err := CreatePurchaser(db, admin, "buyer", "123456", "123456", now); err != nil { t.Fatalf("创建采购员失败: %v", err) } buyer, err := repository.FindUserByUsername(db, "BUYER") @@ -26,8 +26,8 @@ func TestCreatePurchaser_固定角色且用户名不区分大小写唯一(t *tes if buyer.Role != model.RolePurchaser || buyer.Status != model.UserActive { t.Fatalf("新账号角色或状态错误: %+v", buyer) } - if buyer.PasswordHash == "buyer-password" || - bcrypt.CompareHashAndPassword([]byte(buyer.PasswordHash), []byte("buyer-password")) != nil { + if buyer.PasswordHash == "123456" || + bcrypt.CompareHashAndPassword([]byte(buyer.PasswordHash), []byte("123456")) != nil { t.Fatal("采购员密码必须保存为可验证的 bcrypt 哈希") } if err := CreatePurchaser(db, admin, "BUYER", "other-password", "other-password", now); !errors.Is(err, repository.ErrUsernameExists) { @@ -73,7 +73,7 @@ func TestResetUserPassword_原子撤销Session并更换凭据(t *testing.T) { } buyer, _ := repository.FindUserByUsername(db, "buyer") token, _, _, _ := Login(db, "buyer", "old-password", now) - if err := ResetUserPassword(db, admin, buyer.UserID, "new-password", "new-password", now.Add(time.Minute)); err != nil { + if err := ResetUserPassword(db, admin, buyer.UserID, "654321", "654321", now.Add(time.Minute)); err != nil { t.Fatal(err) } if _, err := Authenticate(db, token, now.Add(2*time.Minute)); !errors.Is(err, ErrUnauthenticated) { @@ -82,7 +82,7 @@ func TestResetUserPassword_原子撤销Session并更换凭据(t *testing.T) { if _, _, _, err := Login(db, "buyer", "old-password", now.Add(2*time.Minute)); !errors.Is(err, ErrInvalidCredentials) { t.Fatalf("旧密码应失效,实际 %v", err) } - if _, _, _, err := Login(db, "buyer", "new-password", now.Add(2*time.Minute)); err != nil { + if _, _, _, err := Login(db, "buyer", "654321", now.Add(2*time.Minute)); err != nil { t.Fatalf("新密码应可登录: %v", err) } } diff --git a/admin/templates/auth/setup.html b/admin/templates/auth/setup.html index 952c28d..a9d240b 100644 --- a/admin/templates/auth/setup.html +++ b/admin/templates/auth/setup.html @@ -20,14 +20,14 @@
- - 至少 8 个字符;请使用只有你知道的密码。 + 至少 6 个字符;请使用只有你知道的密码。
+ minlength="6" maxlength="72" required autocomplete="new-password">
diff --git a/admin/templates/user/list.html b/admin/templates/user/list.html index 61bbd5b..2c032c3 100644 --- a/admin/templates/user/list.html +++ b/admin/templates/user/list.html @@ -86,14 +86,14 @@
- - 至少 8 个字符。密码不会显示在列表或日志中。 + 至少 6 个字符。密码不会显示在列表或日志中。
+ minlength="6" maxlength="72" required autocomplete="new-password">
@@ -117,13 +117,14 @@

账号:

- + 至少 6 个字符。重置成功后原有登录会立即失效。
+ minlength="6" maxlength="72" required autocomplete="new-password">
diff --git a/admin/user_integration_test.go b/admin/user_integration_test.go index 29c2df9..1e30ce3 100644 --- a/admin/user_integration_test.go +++ b/admin/user_integration_test.go @@ -50,6 +50,10 @@ 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 { + t.Fatalf("创建和重置密码表单没有统一使用 6 个字符规则") + } for _, secret := range []string{"admin-password", "buyer-password"} { if strings.Contains(adminPage.Body.String(), secret) { t.Fatalf("用户管理页泄露密码 %q", secret) diff --git a/docs/admin/01-requirements.md b/docs/admin/01-requirements.md index e0820b7..551d296 100644 --- a/docs/admin/01-requirements.md +++ b/docs/admin/01-requirements.md @@ -437,6 +437,8 @@ MVP 之后: - 导入 1 万行 Excel 应在可接受时间内完成,并显示进度或结果统计。 - 所有写操作有 CSRF 防护,所有 SQL 参数化。 - 密码只保存成熟算法生成的哈希;Session 有过期、退出和账号禁用失效机制。 +- 首次管理员、采购员初始密码和重置密码统一要求至少 6 个字符,最多 72 个字节; + 前端提示与服务端校验必须一致。 - 日志、页面、导出不含 token、密码、Cookie。 - 数据放程序旁边的 `data/`,便携模式,见 [02 架构](02-architecture.md) §6。 diff --git a/docs/admin/05-ui-specification.md b/docs/admin/05-ui-specification.md index 1d6c98e..76f6913 100644 --- a/docs/admin/05-ui-specification.md +++ b/docs/admin/05-ui-specification.md @@ -642,6 +642,7 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD ``` - 用户名可以预填 `admin`,但用户可以修改;不得提供固定默认密码。 +- 密码和确认密码最少 6 个字符;字段下方显示同样的辅助文字。 - 密码框不回显,校验失败时清空密码和确认密码。 - 初始化成功后跳转登录页;此后访问 `/setup` 不再显示初始化表单。 - 两个浏览器并发初始化时,最多一个成功,另一个提示“管理员已经初始化,请登录”。 @@ -670,6 +671,8 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD - 管理员只能新增 `purchaser` 角色,不通过普通页面新增第二个管理员。 - 操作包含“重置密码”“禁用/启用”;第一版不做物理删除。 +- 新增采购员和重置密码的密码、确认密码字段最少 6 个字符,浏览器原生约束、 + 辅助文字与服务端错误必须一致。 - 禁用账号或重置密码前二次确认;成功后该用户现有 Session 全部失效。 - 不能禁用最后一个有效管理员,也不能让唯一管理员禁用自己。 - 完整密码不在列表、弹窗关闭后的页面或日志中出现。 diff --git a/docs/admin/06-quality-security.md b/docs/admin/06-quality-security.md index 189ea8f..9d033b1 100644 --- a/docs/admin/06-quality-security.md +++ b/docs/admin/06-quality-security.md @@ -29,6 +29,7 @@ - SKU 映射复用:第二次匹配同一 SKU 应自动带出; - 在线状态派生:`last_seen_at` 刚好在边界前后。 - 密码哈希校验:正确密码成功,错误密码失败,数据库不出现明文密码; +- 密码长度边界:5 个字符拒绝、6 个字符接受;初始化、创建和重置规则一致; - 角色校验:管理员可以管理用户,采购员访问用户管理返回 `403`; - 最后管理员保护:不能禁用最后一个有效管理员。 - 客户端归属:一人多客户端、一台客户端唯一当前负责人、转交/解绑历史完整; @@ -106,6 +107,8 @@ - `[建议]` MVP 只监听 `127.0.0.1`,不对外暴露。要给内网用再单独评估。 - `[必须]` 不提供固定默认密码和公开注册;第一位管理员由用户首次初始化。 - `[必须]` 密码使用成熟算法哈希,禁止自创加密、明文保存或可逆加密。 +- `[必须]` 密码最少 6 个字符、最多 72 个字节;初始化、创建采购员、重置密码 + 的服务端校验和 HTML 表单约束保持一致。 - `[必须]` 首次管理员创建必须在数据库写事务中完成,并发请求最多一个成功。 - `[必须]` 登录成功后使用新的随机 Session Token,数据库只保存其 SHA-256 哈希。 - `[必须]` 登录 Cookie 设置 `HttpOnly`、`SameSite=Lax`、`Path=/`;HTTPS 部署时设置 `Secure`。