diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index af3871d..3e3894c 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Business-Rules-and-Glossary.- -wiki_revision: 3f2294d5f038cc6f3c3e707a44f528c697c01b38 -synchronized_at: 2026-09-10T09:38:15Z +wiki_revision: 0fed160ce43d6ad5cecc1f9f2acdeca91a72fda6 +synchronized_at: 2026-09-10T12:02:08Z # 业务规则与术语 @@ -58,3 +58,10 @@ M0 固定首发语言语料、词条身份规则、短语选择与重叠规则 - 每次请求读取账号当前状态与会话有效期;学习数据由认证身份确定,管理员权限不能解除学习接口的本人过滤。 - 两端分别使用 lexgo-admin-token、lexgo-learner-token 的 sessionStorage;401、退出和账号切换清除私人状态,以代次检查阻止迟到响应恢复旧数据。管理端拒绝学习者并撤销其新会话;撤销服务异常会提示未确认,不冒充服务端退出成功。 - 新建账号拥有英语空空间。此处没有书籍、阅读、词典或复习功能,不把空页面视为学习业务已实现。 + + +## 已有账号登录与新密码规则 + +登录仅校验用户名格式及密码非空、不超过 bcrypt 的 72 字节上限,随后校验数据库中已有的密码哈希;不再用新建账号的密码长度下限拒绝已有凭据。错误密码继续返回 401,空密码、超长密码和非法用户名返回 400。登录限流、bcrypt 校验、禁用账号和会话撤销机制不变。 + +管理端创建账号、常规重置密码和首次 bootstrap 继续要求 10~72 字节。用户明确要求的本机指定账号凭据由管理员维护事务设置,不构成通用创建规则变更;密码更新须同时撤销该账号旧会话。密码值只能保存在本机安全配置中。 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index c06d7c6..ea74161 100644 --- a/docs/04-local-development-and-verification.md +++ b/docs/04-local-development-and-verification.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Local-Development-and-Verification wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Local-Development-and-Verification.- -wiki_revision: 440098c7120992b136eb2e192af211a871245df1 -synchronized_at: 2026-09-10T11:54:37Z +wiki_revision: 37dd505cecc83c5ab05523ff5fb3339a1652ccd5 +synchronized_at: 2026-09-10T12:02:10Z # 本地开发与验证 @@ -161,3 +161,12 @@ supervisor 直接管理编译后的 Go 进程,运行时不调用 Python。数 后端交接验证:停止时 8000 端口释放,重新启动后 lexgo-api 为 Running、/healthz 返回 200,两端 /api/v1/me 均返回预期 401。原有 11 个实例(含两个前端)的 PID 与状态均未变化。 验证结果:两个实例均为 Running,两端首页 HTTP 200,未登录请求 /api/v1/me 经代理返回预期 HTTP 401;已有 9 个实例的状态和 PID 均未变化。已交接并停止之前的手动前端进程。 + + +## 本机验收账号维护(2026-09-10) + +用户指定的管理账号 admin 已更新密码,并创建普通学习账号 dev 及其独立英语空间。指定密码只保存在忽略的 .local/account-credentials.json;.env.local 的 LEXGO_BOOTSTRAP_PASSWORD 已同步管理员当前密码,但仍不可对已有数据库重复执行 bootstrap。此前 learner_a / learner_b 测试账号保持原样。 + +两个指定账号通过本机管理员事务设置密码哈希并撤销旧会话;这是已获用户授权的本地维护操作。普通管理页面创建/重置密码仍要求 10~72 字节,登录则直接验证已有非空密码,不以新密码下限拒绝已有账号。 + +新增真实 MySQL 回归测试覆盖已有短密码登录、错误密码拒绝、创建/常规重置仍拒绝短密码;已观察测试先失败再通过,完整后端集成测试通过。重新编译并由 supervisor 启动 lexgo-api 后,admin 管理端登录和 dev 学习端登录均成功,dev 的 /accounts 返回 403、英语空间归属正确,退出后的 token 返回 401。 diff --git a/server/app/lexgo/integration_test.go b/server/app/lexgo/integration_test.go index 674b6fc..975e9d0 100644 --- a/server/app/lexgo/integration_test.go +++ b/server/app/lexgo/integration_test.go @@ -100,6 +100,38 @@ func randomName(prefix string) string { return prefix + hex.EncodeToString(b) } +func TestMySQLLoginUsesStoredPasswordWithoutChangingCreationPolicy(t *testing.T) { + db := testDB(t) + // Fictional legacy credential: login verifies the stored hash independently + // of the policy applied when creating accounts or setting new passwords. + legacyPassword := strings.Repeat("z", 6) + u := admin.SysUser{Username: randomName("legacy"), Password: legacyPassword, RoleId: 1, Status: "2"} + if err := db.Create(&u).Error; err != nil { + t.Fatal("fixture creation failed") + } + r := Router(db, time.Now) + token := loginToken(t, r, u.Username, legacyPassword) + for _, password := range []string{strings.Repeat("y", 6), strings.Repeat("z", 5)} { + code, _ := callAPI(t, r, "POST", "/api/v1/login", "", map[string]string{"username": u.Username, "password": password}) + if code != 401 { + t.Fatalf("incorrect stored password status %d, want 401", code) + } + } + code, _ := callAPI(t, r, "POST", "/api/v1/accounts", token, map[string]string{"username": randomName("new"), "password": legacyPassword}) + if code != 400 { + t.Fatalf("short new password status %d, want 400", code) + } + learner, err := createAccount(db, randomName("reset"), fixturePassword, 2, u.UserId) + if err != nil { + t.Fatal(err) + } + code, _ = callAPI(t, r, "PATCH", fmt.Sprintf("/api/v1/accounts/%d", learner.ID), token, map[string]string{"password": legacyPassword}) + if code != 400 { + t.Fatalf("short reset password status %d, want 400", code) + } + loginToken(t, r, learner.Username, fixturePassword) +} + func TestMySQLAccountIsolationAndRevocation(t *testing.T) { db := testDB(t) if err := Migrate(db); err != nil { diff --git a/server/app/lexgo/router.go b/server/app/lexgo/router.go index d3f5d70..90ed571 100644 --- a/server/app/lexgo/router.go +++ b/server/app/lexgo/router.go @@ -42,7 +42,7 @@ func Router(db *gorm.DB, now func() time.Time) *gin.Engine { respond(c, 400, nil, err) return } - name, err := credentials(input.Username, input.Password) + name, err := loginCredentials(input.Username, input.Password) if err != nil { respond(c, 400, nil, err) return diff --git a/server/app/lexgo/router_test.go b/server/app/lexgo/router_test.go index c29c6f5..408e541 100644 --- a/server/app/lexgo/router_test.go +++ b/server/app/lexgo/router_test.go @@ -4,6 +4,7 @@ import ( "bytes" "net/http" "net/http/httptest" + "strings" "testing" "time" ) @@ -13,7 +14,7 @@ func TestLoginRejectsEmailAndMissingPasswordBeforeDatabase(t *testing.T) { for _, payload := range []string{ `{"username":"person@example.test","password":"example-password"}`, `{"username":"learner","password":""}`, - `{"username":"learner","password":"short"}`, + `{"username":"learner","password":"` + strings.Repeat("x", 73) + `"}`, } { w := httptest.NewRecorder() q := httptest.NewRequest(http.MethodPost, "/api/v1/login", bytes.NewBufferString(payload)) diff --git a/server/app/lexgo/service.go b/server/app/lexgo/service.go index a7390cf..b95f10d 100644 --- a/server/app/lexgo/service.go +++ b/server/app/lexgo/service.go @@ -62,12 +62,25 @@ func failure(code int, msg string) error { return &apiError{code, msg} } var usernamePattern = regexp.MustCompile(`^[a-z][a-z0-9_.-]{2,31}$`) func credentials(username, password string) (string, error) { + name, err := loginCredentials(username, password) + if err != nil { + return "", err + } + if len(password) < 10 { + return "", failure(400, "密码须为 10~72 字节") + } + return name, nil +} + +// Login verifies an existing hash; account creation and normal password resets +// separately enforce the current minimum password length. +func loginCredentials(username, password string) (string, error) { username = strings.ToLower(strings.TrimSpace(username)) if !usernamePattern.MatchString(username) { return "", failure(400, "账号须为字母开头的 3~32 位字母、数字、点、下划线或连字符") } - if len(password) < 10 || len(password) > 72 { - return "", failure(400, "密码须为 10~72 字节") + if len(password) == 0 || len(password) > 72 { + return "", failure(400, "密码不能为空且不能超过 72 字节") } return username, nil }