fix: 重置密码最少六字节并统一账号校验 (#2)

This commit was merged in pull request #17.
This commit is contained in:
ila
2026-09-10 20:13:38 +08:00
parent b7768c10de
commit 1e77f1026b
7 changed files with 60 additions and 22 deletions
+4 -4
View File
@@ -1,7 +1,7 @@
export function normalizeUsername(value) { return value.trim().toLowerCase() }
export function validPassword(value, minimum = 10) {
export function validPassword(value) {
const bytes = new TextEncoder().encode(value).length
return bytes >= minimum && bytes <= 72
return bytes >= 6 && bytes <= 72
}
export function validUsername(value) { return /^[a-z][a-z0-9_.-]{2,31}$/.test(normalizeUsername(value)) }
export function createSession({ fetch, storage, changed = () => {} }) {
@@ -94,12 +94,12 @@ export function createSession({ fetch, storage, changed = () => {} }) {
async createAccount(username, password) {
authorized()
if (!validUsername(username)) throw new Error('账号须为 3–32 位,以字母开头,仅含字母、数字、点、下划线或短横线')
if (!validPassword(password, 6)) throw new Error('密码须为 6–72 字节')
if (!validPassword(password)) throw new Error('密码须为 6–72 字节')
return request('/accounts', 'POST', { username: normalizeUsername(username), password })
},
async updateAccount(id, patch) {
authorized()
if (patch.password !== undefined && !validPassword(patch.password)) throw new Error('密码须为 10–72 字节')
if (patch.password !== undefined && !validPassword(patch.password)) throw new Error('密码须为 6–72 字节')
if (id === state.user.id && patch.disabled) throw new Error('不能停用当前管理员')
const account = state.accounts.find(item => item.id === id)
if (id === state.user.id || (account && account.role !== 'learner')) throw new Error('管理员账号不支持此操作')
+1 -1
View File
@@ -17,7 +17,7 @@
<el-alert v-if="formError" :title="formError" type="error" :closable="false" />
<el-form :model="form" label-position="top" @submit.prevent="save">
<el-form-item v-if="!target" label="账号"><el-input v-model="form.username" autocomplete="off" maxlength="32" /><div class="hint">3–32 位,以字母开头;可含数字、点、下划线、短横线</div></el-form-item>
<el-form-item label="新密码"><el-input v-model="form.password" type="password" show-password autocomplete="new-password" /><div class="hint">{{ target ? 10 : 6 }}–72 字节;中文等字符会占多个字节</div></el-form-item>
<el-form-item label="新密码"><el-input v-model="form.password" type="password" show-password autocomplete="new-password" /><div class="hint">6–72 字节;中文等字符会占多个字节</div></el-form-item>
<div class="dialog-footer"><el-button :disabled="busy" @click="open = false">取消</el-button><el-button type="primary" native-type="submit" :loading="busy">保存</el-button></div>
</el-form>
</el-dialog>
+7 -2
View File
@@ -15,7 +15,7 @@ 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 () => {
test('creation and reset accept six bytes but reject five and over 72', async () => {
const writes = []
const { session } = setup(async (url, options) => {
if (url.endsWith('/login')) return response({ token: 'fictional-admin-token', user: admin })
@@ -29,7 +29,12 @@ test('creation accepts six bytes but rejects five and over 72; reset retains its
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) }))
await assert.rejects(session.updateAccount(learner.id, { password: 'z'.repeat(5) }))
await assert.rejects(session.updateAccount(learner.id, { password: 'z'.repeat(73) }))
for (const password of ['z'.repeat(6), '虚构', 'z'.repeat(72)]) {
await session.updateAccount(learner.id, { password })
assert.equal(writes.at(-1).password, password)
}
})
test('learner login is rejected, revoked, and never stored in admin session', async () => {
+7 -4
View File
@@ -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: 4e6c7ea9699ee6a6bc2873ef44e1f0b04e1baf2b
synchronized_at: 2026-09-10T12:09:31Z
wiki_revision: c6044b9b3f863c90363563e079dff3e3fcb89bd8
synchronized_at: 2026-09-10T12:13:06Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -64,7 +64,10 @@ M0 固定首发语言语料、词条身份规则、短语选择与重叠规则
登录仅校验用户名格式及密码非空、不超过 bcrypt 的 72 字节上限,随后校验数据库中已有的密码哈希;不再用新建账号的密码长度下限拒绝已有凭据。错误密码继续返回 401,空密码、超长密码和非法用户名返回 400。登录限流、bcrypt 校验、禁用账号和会话撤销机制不变。
管理端新增学习账号要求 6~72 字节;常规重置密码和首次 bootstrap 仍要求 10~72 字节。用户明确要求的本机指定账号凭据由管理员维护事务设置,不构成通用创建规则变更;密码更新须同时撤销该账号旧会话。密码值只能保存在本机安全配置中。
管理端新增学习账号与重置密码均要求 6~72 字节;首次 bootstrap 仍要求 10~72 字节。用户明确要求的本机指定账号凭据由管理员维护事务设置,不构成通用创建规则变更;密码更新须同时撤销该账号旧会话。密码值只能保存在本机安全配置中。
用户随后确认:管理端新增学习账号的密码下限调整为 6 个 UTF-8 字节、上限仍为 72 字节。POST /accounts 与新增表单同时执行此规则;5 字节及 73 字节拒绝,6 字节与 72 字节允许。中文等字符按 UTF-8 字节数计算。重置弹窗继续显示并执行 10~72 字节,不改变已有账号密码或登录验证。
用户随后确认:管理端新增学习账号的密码下限调整为 6 个 UTF-8 字节、上限仍为 72 字节。POST /accounts 与新增表单同时执行此规则;5 字节及 73 字节拒绝,6 字节与 72 字节允许。中文等字符按 UTF-8 字节数计算。重置弹窗现也显示并执行 6~72 字节;规则调整不自动修改已有账号密码。
用户进一步确认重置密码下限也为 6 字节。新增和重置共用同一密码校验,前后端均为 6~72 UTF-8 字节;重置密码事务仍撤销该用户全部旧会话。首次管理员 bootstrap 的 10 字节下限保持原样。
@@ -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: c9c0de7a0bb1e610c1cb31b829f286a40048c855
synchronized_at: 2026-09-10T12:09:34Z
wiki_revision: a12e404e317f2f106936b99147f993d7cc6da608
synchronized_at: 2026-09-10T12:13:08Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -167,9 +167,12 @@ supervisor 直接管理编译后的 Go 进程,运行时不调用 Python。数
用户指定的管理账号 admin 已更新密码,并创建普通学习账号 dev 及其独立英语空间。指定密码只保存在忽略的 .local/account-credentials.json;.env.local 的 LEXGO_BOOTSTRAP_PASSWORD 已同步管理员当前密码,但仍不可对已有数据库重复执行 bootstrap。此前 learner_a / learner_b 测试账号保持原样。
两个指定账号通过本机管理员事务设置密码哈希并撤销旧会话;这是已获用户授权的本地维护操作。普通管理页面新增账号要求 6~72 字节、重置密码要求 10~72 字节,登录则直接验证已有非空密码,不以新密码下限拒绝已有账号。
两个指定账号通过本机管理员事务设置密码哈希并撤销旧会话;这是已获用户授权的本地维护操作。普通管理页面新增账号和重置密码均要求 6~72 字节,登录则直接验证已有非空密码,不以新密码下限拒绝已有账号。
新增真实 MySQL 回归测试覆盖已有短密码登录、错误密码拒绝、创建/常规重置仍拒绝短密码;已观察测试先失败再通过,完整后端集成测试通过。重新编译并由 supervisor 启动 lexgo-api 后,admin 管理端登录和 dev 学习端登录均成功,dev 的 /accounts 返回 403、英语空间归属正确,退出后的 token 返回 401。
新增账号 6 字节密码验证:管理端会话测试 18 项及完整后端 MySQL 集成测试通过;新增覆盖 5/6/72/73 字节和多字节字符,创建成功后验证登录。管理端 lint、生产构建及后端构建通过,lexgo-api 已由 supervisor 重新启动。
重置密码下限已同步为 6 字节。管理端测试与真实 MySQL 集成测试覆盖 5/6/72/73 字节及多字节字符,验证新密码登录成功、旧密码和旧会话被拒绝。管理员页面的新增/重置提示均为 6–72 字节;本次不修改现有账号密码。
+23 -4
View File
@@ -137,11 +137,30 @@ func TestMySQLLoginUsesStoredPasswordWithoutChangingCreationPolicy(t *testing.T)
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)
for _, password := range []string{strings.Repeat("z", 5), strings.Repeat("z", 73)} {
code, _ = callAPI(t, r, "PATCH", fmt.Sprintf("/api/v1/accounts/%d", learner.ID), token, map[string]string{"password": password})
if code != 400 {
t.Fatalf("invalid reset password status %d, want 400", code)
}
}
oldPassword := fixturePassword
for _, password := range []string{legacyPassword, "虚构", strings.Repeat("z", 72)} {
oldToken := loginToken(t, r, learner.Username, oldPassword)
code, _ = callAPI(t, r, "PATCH", fmt.Sprintf("/api/v1/accounts/%d", learner.ID), token, map[string]string{"password": password})
if code != 200 {
t.Fatalf("valid reset password status %d, want 200", code)
}
code, _ = callAPI(t, r, "GET", "/api/v1/me", oldToken, nil)
if code != 401 {
t.Fatal("reset must revoke old session")
}
code, _ = callAPI(t, r, "POST", "/api/v1/login", "", map[string]string{"username": learner.Username, "password": oldPassword})
if code != 401 {
t.Fatal("reset must reject old password")
}
loginToken(t, r, learner.Username, password)
oldPassword = password
}
loginToken(t, r, learner.Username, fixturePassword)
}
func TestMySQLAccountIsolationAndRevocation(t *testing.T) {
+12 -4
View File
@@ -86,13 +86,21 @@ 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) {
func accountCredentials(username, password string) (string, error) {
name, err := loginCredentials(username, password)
if err != nil {
return Account{}, err
return "", err
}
if len(password) < 6 {
return Account{}, failure(400, "密码须为 6~72 字节")
return "", failure(400, "密码须为 6~72 字节")
}
return name, nil
}
func createAccount(tx *gorm.DB, username, password string, role, actor int) (Account, error) {
name, err := accountCredentials(username, password)
if err != nil {
return Account{}, err
}
u := admin.SysUser{Username: name, Password: password, RoleId: role, Status: "2"}
u.CreateBy = actor
@@ -232,7 +240,7 @@ func updateAccount(tx *gorm.DB, id, actor int, input UpdateAccount) (Account, er
updates["status"] = u.Status
}
if input.Password != nil {
if _, err := credentials(u.Username, *input.Password); err != nil {
if _, err := accountCredentials(u.Username, *input.Password); err != nil {
return Account{}, err
}
hash, err := bcrypt.GenerateFromPassword([]byte(*input.Password), bcrypt.DefaultCost)