diff --git a/admin/src/session.mjs b/admin/src/session.mjs index 45e9374..3fccfee 100644 --- a/admin/src/session.mjs +++ b/admin/src/session.mjs @@ -1,7 +1,7 @@ export function normalizeUsername(value) { return value.trim().toLowerCase() } -export function validPassword(value) { +export function validPassword(value, minimum = 10) { const bytes = new TextEncoder().encode(value).length - return bytes >= 10 && bytes <= 72 + return bytes >= minimum && bytes <= 72 } export function validUsername(value) { return /^[a-z][a-z0-9_.-]{2,31}$/.test(normalizeUsername(value)) } export function createSession({ fetch, storage, changed = () => {} }) { @@ -94,7 +94,7 @@ export function createSession({ fetch, storage, changed = () => {} }) { async createAccount(username, password) { authorized() if (!validUsername(username)) throw new Error('账号须为 3–32 位,以字母开头,仅含字母、数字、点、下划线或短横线') - if (!validPassword(password)) throw new Error('密码须为 10–72 字节') + if (!validPassword(password, 6)) throw new Error('密码须为 6–72 字节') return request('/accounts', 'POST', { username: normalizeUsername(username), password }) }, async updateAccount(id, patch) { diff --git a/admin/src/views/Accounts.vue b/admin/src/views/Accounts.vue index a1d873d..48c6589 100644 --- a/admin/src/views/Accounts.vue +++ b/admin/src/views/Accounts.vue @@ -17,7 +17,7 @@
3–32 位,以字母开头;可含数字、点、下划线、短横线
-
10–72 字节;中文等字符会占多个字节
+
{{ target ? 10 : 6 }}–72 字节;中文等字符会占多个字节
diff --git a/admin/tests/session.test.mjs b/admin/tests/session.test.mjs index 8ebdc7b..5054f81 100644 --- a/admin/tests/session.test.mjs +++ b/admin/tests/session.test.mjs @@ -15,6 +15,23 @@ test('normalizes account names and enforces UTF-8 password byte limits', () => { assert.equal(validPassword('短'), false) assert.equal(validPassword('虚'.repeat(25)), false) }) +test('creation accepts six bytes but rejects five and over 72; reset retains its minimum', async () => { + const writes = [] + const { session } = setup(async (url, options) => { + if (url.endsWith('/login')) return response({ token: 'fictional-admin-token', user: admin }) + writes.push(JSON.parse(options.body)) + return response(learner) + }) + await session.login(admin.username, 'fictional-password') + await assert.rejects(session.createAccount('fixture.new', 'z'.repeat(5))) + await assert.rejects(session.createAccount('fixture.new', 'z'.repeat(73))) + await session.createAccount('fixture.new', 'z'.repeat(6)) + await session.createAccount('fixture.unicode', '虚构') + await session.createAccount('fixture.maximum', 'z'.repeat(72)) + assert.equal(writes.length, 3) + await assert.rejects(session.updateAccount(learner.id, { password: 'z'.repeat(6) })) +}) + test('learner login is rejected, revoked, and never stored in admin session', async () => { const requests = [] const { session, data } = setup(async (url, options) => { diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 3e3894c..40666ee 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: 0fed160ce43d6ad5cecc1f9f2acdeca91a72fda6 -synchronized_at: 2026-09-10T12:02:08Z +wiki_revision: 4e6c7ea9699ee6a6bc2873ef44e1f0b04e1baf2b +synchronized_at: 2026-09-10T12:09:31Z # 业务规则与术语 @@ -64,4 +64,7 @@ M0 固定首发语言语料、词条身份规则、短语选择与重叠规则 登录仅校验用户名格式及密码非空、不超过 bcrypt 的 72 字节上限,随后校验数据库中已有的密码哈希;不再用新建账号的密码长度下限拒绝已有凭据。错误密码继续返回 401,空密码、超长密码和非法用户名返回 400。登录限流、bcrypt 校验、禁用账号和会话撤销机制不变。 -管理端创建账号、常规重置密码和首次 bootstrap 继续要求 10~72 字节。用户明确要求的本机指定账号凭据由管理员维护事务设置,不构成通用创建规则变更;密码更新须同时撤销该账号旧会话。密码值只能保存在本机安全配置中。 +管理端新增学习账号要求 6~72 字节;常规重置密码和首次 bootstrap 仍要求 10~72 字节。用户明确要求的本机指定账号凭据由管理员维护事务设置,不构成通用创建规则变更;密码更新须同时撤销该账号旧会话。密码值只能保存在本机安全配置中。 + + +用户随后确认:管理端新增学习账号的密码下限调整为 6 个 UTF-8 字节、上限仍为 72 字节。POST /accounts 与新增表单同时执行此规则;5 字节及 73 字节拒绝,6 字节与 72 字节允许。中文等字符按 UTF-8 字节数计算。重置弹窗继续显示并执行 10~72 字节,不改变已有账号密码或登录验证。 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index ea74161..12f78be 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: 37dd505cecc83c5ab05523ff5fb3339a1652ccd5 -synchronized_at: 2026-09-10T12:02:10Z +wiki_revision: c9c0de7a0bb1e610c1cb31b829f286a40048c855 +synchronized_at: 2026-09-10T12:09:34Z # 本地开发与验证 @@ -167,6 +167,9 @@ supervisor 直接管理编译后的 Go 进程,运行时不调用 Python。数 用户指定的管理账号 admin 已更新密码,并创建普通学习账号 dev 及其独立英语空间。指定密码只保存在忽略的 .local/account-credentials.json;.env.local 的 LEXGO_BOOTSTRAP_PASSWORD 已同步管理员当前密码,但仍不可对已有数据库重复执行 bootstrap。此前 learner_a / learner_b 测试账号保持原样。 -两个指定账号通过本机管理员事务设置密码哈希并撤销旧会话;这是已获用户授权的本地维护操作。普通管理页面创建/重置密码仍要求 10~72 字节,登录则直接验证已有非空密码,不以新密码下限拒绝已有账号。 +两个指定账号通过本机管理员事务设置密码哈希并撤销旧会话;这是已获用户授权的本地维护操作。普通管理页面新增账号要求 6~72 字节、重置密码要求 10~72 字节,登录则直接验证已有非空密码,不以新密码下限拒绝已有账号。 新增真实 MySQL 回归测试覆盖已有短密码登录、错误密码拒绝、创建/常规重置仍拒绝短密码;已观察测试先失败再通过,完整后端集成测试通过。重新编译并由 supervisor 启动 lexgo-api 后,admin 管理端登录和 dev 学习端登录均成功,dev 的 /accounts 返回 403、英语空间归属正确,退出后的 token 返回 401。 + + +新增账号 6 字节密码验证:管理端会话测试 18 项及完整后端 MySQL 集成测试通过;新增覆盖 5/6/72/73 字节和多字节字符,创建成功后验证登录。管理端 lint、生产构建及后端构建通过,lexgo-api 已由 supervisor 重新启动。 diff --git a/server/app/lexgo/integration_test.go b/server/app/lexgo/integration_test.go index 975e9d0..6f769f2 100644 --- a/server/app/lexgo/integration_test.go +++ b/server/app/lexgo/integration_test.go @@ -117,10 +117,22 @@ func TestMySQLLoginUsesStoredPasswordWithoutChangingCreationPolicy(t *testing.T) 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}) + code, _ := callAPI(t, r, "POST", "/api/v1/accounts", token, map[string]string{"username": randomName("new"), "password": strings.Repeat("z", 5)}) if code != 400 { t.Fatalf("short new password status %d, want 400", code) } + for _, password := range []string{strings.Repeat("z", 6), "虚构", strings.Repeat("z", 72)} { + name := randomName("six") + code, _ = callAPI(t, r, "POST", "/api/v1/accounts", token, map[string]string{"username": name, "password": password}) + if code != 201 { + t.Fatalf("valid new password status %d, want 201", code) + } + loginToken(t, r, name, password) + } + code, _ = callAPI(t, r, "POST", "/api/v1/accounts", token, map[string]string{"username": randomName("long"), "password": strings.Repeat("z", 73)}) + if code != 400 { + t.Fatalf("long new password status %d, want 400", code) + } learner, err := createAccount(db, randomName("reset"), fixturePassword, 2, u.UserId) if err != nil { t.Fatal(err) diff --git a/server/app/lexgo/service.go b/server/app/lexgo/service.go index b95f10d..8defcd2 100644 --- a/server/app/lexgo/service.go +++ b/server/app/lexgo/service.go @@ -87,10 +87,13 @@ func loginCredentials(username, password string) (string, error) { func digest(token string) string { v := sha256.Sum256([]byte(token)); return hex.EncodeToString(v[:]) } func createAccount(tx *gorm.DB, username, password string, role, actor int) (Account, error) { - name, err := credentials(username, password) + name, err := loginCredentials(username, password) if err != nil { return Account{}, err } + if len(password) < 6 { + return Account{}, failure(400, "密码须为 6~72 字节") + } u := admin.SysUser{Username: name, Password: password, RoleId: role, Status: "2"} u.CreateBy = actor // Keep the upstream bcrypt BeforeCreate hook; never use BeforeUpdate for status changes.