diff --git a/backend/controllers/security.go b/backend/controllers/security.go index bb4e1fa..acfd7a2 100644 --- a/backend/controllers/security.go +++ b/backend/controllers/security.go @@ -16,7 +16,6 @@ import ( "git.pcux.cn/xuans/Smail/dto/response" "git.pcux.cn/xuans/Smail/models" "git.pcux.cn/xuans/Smail/services/access" - "git.pcux.cn/xuans/Smail/services/auth" "git.pcux.cn/xuans/Smail/services/credentials" "git.pcux.cn/xuans/Smail/services/externalemail" "git.pcux.cn/xuans/Smail/services/recovery" @@ -422,9 +421,11 @@ func RevokeRecoveryCodes(ctx *context.Context, w http.ResponseWriter, req *http. type protocolCredentialRequest struct { Protocol string `json:"protocol"` Enabled *bool `json:"enabled,omitempty"` + Password string `json:"password"` } func requireProtocolCredentialContext(ctx *context.Context, w http.ResponseWriter) (*credentials.Service, bool) { + w.Header().Set("Cache-Control", "no-store") if ctx == nil || ctx.UserID <= 0 { writeSecurityError(w, response.NeedLogin, "请先登录") return nil, false @@ -463,6 +464,9 @@ func SetProtocolCredentialEnabled(ctx *context.Context, w http.ResponseWriter, r writeSecurityError(w, response.ParamsError, "协议设置请求无效") return } + if !requireProtocolCredentialPassword(ctx, w, req, input.Password) { + return + } if err := service.SetProtocolEnabled(ctx.UserID, input.Protocol, *input.Enabled); err != nil { writeSecurityError(w, response.ParamsError, "协议设置失败") return @@ -481,6 +485,9 @@ func GenerateProtocolCredential(ctx *context.Context, w http.ResponseWriter, req writeSecurityError(w, response.ParamsError, "协议请求无效") return } + if !requireProtocolCredentialPassword(ctx, w, req, input.Password) { + return + } code, err := service.GenerateProtocolCode(ctx.UserID, input.Protocol) if err != nil { if writePasswordBusy(w, err) { @@ -504,6 +511,9 @@ func RevokeProtocolCredential(ctx *context.Context, w http.ResponseWriter, req * writeSecurityError(w, response.ParamsError, "协议请求无效") return } + if !requireProtocolCredentialPassword(ctx, w, req, input.Password) { + return + } if err := service.RevokeProtocolCode(ctx.UserID, input.Protocol); err != nil { writeSecurityError(w, response.ParamsError, "撤销授权码失败") return @@ -525,11 +535,7 @@ func RevealProtocolCredentials(ctx *context.Context, w http.ResponseWriter, req writeSecurityError(w, response.ParamsError, "请输入当前账号密码进行安全验证") return } - if _, err := auth.AuthenticateUserByID(ctx.UserID, input.Password); err != nil { - if writePasswordBusy(w, err) { - return - } - writeSecurityError(w, response.ParamsError, "密码验证失败") + if !requireProtocolCredentialPassword(ctx, w, req, input.Password) { return } codes, err := service.RevealProtocolCodes(ctx.UserID) diff --git a/backend/controllers/security_authentication_test.go b/backend/controllers/security_authentication_test.go index 76285f2..0f5110d 100644 --- a/backend/controllers/security_authentication_test.go +++ b/backend/controllers/security_authentication_test.go @@ -2,6 +2,7 @@ package controllers import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" "strings" @@ -32,6 +33,11 @@ func TestSecurityHandlersRejectUnauthenticatedRequests(t *testing.T) { {name: "generate protocol credential", hand: GenerateProtocolCredential, body: `{"protocol":"smtp"}`}, {name: "revoke protocol credential", hand: RevokeProtocolCredential, body: `{"protocol":"smtp"}`}, {name: "reveal protocol credentials", hand: RevealProtocolCredentials, body: `{"password":"FixtureP12"}`}, + {name: "list client credentials", hand: ListClientCredentials}, + {name: "create client credential", hand: CreateClientCredential, body: `{"client_name":"设备","password":"FixtureP12"}`}, + {name: "regenerate client credential", hand: RegenerateClientCredential, body: `{"client_id":1,"password":"FixtureP12"}`}, + {name: "revoke client credential", hand: RevokeClientCredential, body: `{"client_id":1,"password":"FixtureP12"}`}, + {name: "reveal client credentials", hand: RevealClientCredentials, body: `{"password":"FixtureP12"}`}, {name: "request verification", hand: RequestVerification, body: `{"purpose":"login","account":"smail","email":"user@example.com"}`}, {name: "verify verification", hand: VerifyVerification, body: `{"id":"challenge","account":"smail","email":"user@example.com","code":"12345678","purpose":"login"}`}, {name: "list external emails", hand: ListExternalEmails}, @@ -157,7 +163,7 @@ func TestProtocolCredentialHandlersManageCurrentUserCredential(t *testing.T) { setRequest := httptest.NewRequest( http.MethodPut, "/api/security/credentials/protocols/enabled", - strings.NewReader(`{"protocol":"SMTP","enabled":true}`), + strings.NewReader(`{"protocol":"SMTP","enabled":true,"password":"FixtureP12"}`), ) SetProtocolCredentialEnabled(ctx, setRecorder, setRequest) var setResult response.Response @@ -172,7 +178,7 @@ func TestProtocolCredentialHandlersManageCurrentUserCredential(t *testing.T) { generateRequest := httptest.NewRequest( http.MethodPost, "/api/security/credentials/protocols/generate", - strings.NewReader(`{"protocol":"smtp"}`), + strings.NewReader(`{"protocol":"smtp","password":"FixtureP12"}`), ) GenerateProtocolCredential(ctx, generateRecorder, generateRequest) var generateResult response.Response @@ -251,7 +257,7 @@ func TestProtocolCredentialHandlersManageCurrentUserCredential(t *testing.T) { revokeRequest := httptest.NewRequest( http.MethodPost, "/api/security/credentials/protocols/revoke", - strings.NewReader(`{"protocol":"smtp"}`), + strings.NewReader(`{"protocol":"smtp","password":"FixtureP12"}`), ) RevokeProtocolCredential(ctx, revokeRecorder, revokeRequest) var revokeResult response.Response @@ -273,3 +279,117 @@ func TestProtocolCredentialHandlersManageCurrentUserCredential(t *testing.T) { } } } + +func TestClientCredentialEntryPointsRequireOwnPassword(t *testing.T) { + _, _, engine, user, other := newPublicSecurityFixture(t) + service, err := credentials.NewService(engine) + if err != nil { + t.Fatal(err) + } + service.SetVaultRoot(t.TempDir()) + previousDB, previousRuntime := db.Instance, securityRuntime + previousUserLimiter, previousIPLimiter := reauthUserAttempts, reauthIPAttempts + db.Instance, securityRuntime.credentials = engine, service + t.Cleanup(func() { + db.Instance, securityRuntime = previousDB, previousRuntime + reauthUserAttempts, reauthIPAttempts = previousUserLimiter, previousIPLimiter + }) + _, client, err := service.CreateClientCredential(user.ID, "原设备") + if err != nil { + t.Fatal(err) + } + if err := service.SetProtocolEnabled(user.ID, "smtp", true); err != nil { + t.Fatal(err) + } + if _, err := service.GenerateProtocolCode(user.ID, "smtp"); err != nil { + t.Fatal(err) + } + status := func() string { + t.Helper() + clients, err := service.ListClientCredentials(user.ID) + if err != nil { + t.Fatal(err) + } + protocols, err := service.ListProtocolCredentials(user.ID) + if err != nil { + t.Fatal(err) + } + data, err := json.Marshal([]any{clients, protocols}) + if err != nil { + t.Fatal(err) + } + return string(data) + } + before := status() + entries := []struct { + name string + hand func(*appcontext.Context, http.ResponseWriter, *http.Request) + body string + }{ + {"create", CreateClientCredential, `{"client_name":"新设备"`}, + {"regenerate", RegenerateClientCredential, fmt.Sprintf(`{"client_id":%d`, client.ID)}, + {"revoke", RevokeClientCredential, fmt.Sprintf(`{"client_id":%d`, client.ID)}, + {"reveal", RevealClientCredentials, `{"unused":true`}, + {"enable protocol", SetProtocolCredentialEnabled, `{"protocol":"imap","enabled":true`}, + {"disable protocol", SetProtocolCredentialEnabled, `{"protocol":"smtp","enabled":false`}, + {"generate protocol", GenerateProtocolCredential, `{"protocol":"smtp"`}, + {"revoke protocol", RevokeProtocolCredential, `{"protocol":"smtp"`}, + {"reveal protocol", RevealProtocolCredentials, `{"unused":true`}, + } + for _, role := range []access.Role{access.RoleUser, access.RoleAdmin, access.RoleSuperAdmin} { + for _, entry := range entries { + t.Run(string(role)+"/"+entry.name, func(t *testing.T) { + reauthUserAttempts, reauthIPAttempts = newLoginLimiter(10), newLoginLimiter(50) + ctx := &appcontext.Context{UserID: user.ID, Role: role, RecentAuth: true} + for _, suffix := range []string{`}`, `,"password":"incorrect"}`, `,"password":" FixtureP12 "}`} { + recorder := httptest.NewRecorder() + entry.hand(ctx, recorder, httptest.NewRequest(http.MethodPost, "/api/security", strings.NewReader(entry.body+suffix))) + var result response.Response + if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil || result.ErrorNo == 0 { + t.Fatal("missing or incorrect password must not allow credential operations") + } + if recorder.Header().Get("Cache-Control") != "no-store" { + t.Fatal("credential responses must not be cached") + } + if status() != before { + t.Fatal("rejected operation changed credential state") + } + } + }) + } + } + // 本人验证通过也不能重置或撤销其他账号的客户端。 + reauthUserAttempts, reauthIPAttempts = newLoginLimiter(10), newLoginLimiter(50) + for _, hand := range []func(*appcontext.Context, http.ResponseWriter, *http.Request){RegenerateClientCredential, RevokeClientCredential} { + recorder := httptest.NewRecorder() + body := fmt.Sprintf(`{"client_id":%d,"password":"FixtureP12"}`, client.ID) + hand(&appcontext.Context{UserID: other.ID, Role: access.RoleSuperAdmin}, recorder, httptest.NewRequest(http.MethodPost, "/api/security", strings.NewReader(body))) + var result response.Response + if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil || result.ErrorNo == 0 || status() != before { + t.Fatal("credential operations must remain scoped to the current account") + } + } +} + +func TestClientCredentialPasswordFailuresShareRateLimit(t *testing.T) { + _, _, engine, user, _ := newPublicSecurityFixture(t) + service, err := credentials.NewService(engine) + if err != nil { + t.Fatal(err) + } + previousDB, previousRuntime := db.Instance, securityRuntime + previousUserLimiter, previousIPLimiter := reauthUserAttempts, reauthIPAttempts + db.Instance, securityRuntime.credentials = engine, service + reauthUserAttempts, reauthIPAttempts = newLoginLimiter(1), newLoginLimiter(10) + t.Cleanup(func() { + db.Instance, securityRuntime = previousDB, previousRuntime + reauthUserAttempts, reauthIPAttempts = previousUserLimiter, previousIPLimiter + }) + ctx := &appcontext.Context{UserID: user.ID, RecentAuth: true} + CreateClientCredential(ctx, httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/api/security", strings.NewReader(`{"client_name":"设备","password":"incorrect"}`))) + recorder := httptest.NewRecorder() + RevealProtocolCredentials(ctx, recorder, httptest.NewRequest(http.MethodPost, "/api/security", strings.NewReader(`{"password":"FixtureP12"}`))) + if recorder.Code != http.StatusTooManyRequests { + t.Fatal("switching from client to legacy protocol entry must not bypass password failure limits") + } +} diff --git a/backend/controllers/security_client_credentials.go b/backend/controllers/security_client_credentials.go index a9f5513..7ab7e05 100644 --- a/backend/controllers/security_client_credentials.go +++ b/backend/controllers/security_client_credentials.go @@ -2,6 +2,7 @@ package controllers import ( "net/http" + "strconv" "strings" "git.pcux.cn/xuans/Smail/dto/response" @@ -9,6 +10,32 @@ import ( "git.pcux.cn/xuans/Smail/utils/context" ) +// requireProtocolCredentialPassword 对新旧客户端入口统一执行本人验证,不复用登录时的验证状态。 +func requireProtocolCredentialPassword(ctx *context.Context, w http.ResponseWriter, req *http.Request, plain string) bool { + w.Header().Set("Cache-Control", "no-store") + if plain == "" { + writeSecurityError(w, response.ParamsError, "请输入当前账号密码进行安全验证") + return false + } + userKey := reauthUserAttempts.accountKey(strconv.Itoa(ctx.UserID)) + ipKey := reauthIPAttempts.ipKey(req) + if reauthUserAttempts.blocked(userKey) || reauthIPAttempts.blocked(ipKey) { + writeReauthError(w, http.StatusTooManyRequests, "REAUTH_RATE_LIMITED", "尝试次数过多,请稍后再试") + return false + } + if _, err := auth.AuthenticateUserByID(ctx.UserID, plain); err != nil { + if writePasswordBusy(w, err) { + return false + } + reauthUserAttempts.recordFailure(userKey) + reauthIPAttempts.recordFailure(ipKey) + writeSecurityError(w, response.ParamsError, "密码验证失败") + return false + } + reauthUserAttempts.clear(userKey) + return true +} + // ListClientCredentials 返回当前用户的客户端授权码列表 func ListClientCredentials(ctx *context.Context, w http.ResponseWriter, req *http.Request) { service, ok := requireProtocolCredentialContext(ctx, w) @@ -31,11 +58,15 @@ func CreateClientCredential(ctx *context.Context, w http.ResponseWriter, req *ht } var input struct { ClientName string `json:"client_name"` + Password string `json:"password"` } if err := decodeJSONRequest(w, req, &input); err != nil || strings.TrimSpace(input.ClientName) == "" { writeSecurityError(w, response.ParamsError, "请输入客户端名称") return } + if !requireProtocolCredentialPassword(ctx, w, req, input.Password) { + return + } code, item, err := service.CreateClientCredential(ctx.UserID, input.ClientName) if err != nil { if writePasswordBusy(w, err) { @@ -54,12 +85,16 @@ func RegenerateClientCredential(ctx *context.Context, w http.ResponseWriter, req return } var input struct { - ClientID int `json:"client_id"` + ClientID int `json:"client_id"` + Password string `json:"password"` } if err := decodeJSONRequest(w, req, &input); err != nil || input.ClientID <= 0 { writeSecurityError(w, response.ParamsError, "客户端标识无效") return } + if !requireProtocolCredentialPassword(ctx, w, req, input.Password) { + return + } code, err := service.RegenerateClientCredential(ctx.UserID, input.ClientID) if err != nil { if writePasswordBusy(w, err) { @@ -78,12 +113,16 @@ func RevokeClientCredential(ctx *context.Context, w http.ResponseWriter, req *ht return } var input struct { - ClientID int `json:"client_id"` + ClientID int `json:"client_id"` + Password string `json:"password"` } if err := decodeJSONRequest(w, req, &input); err != nil || input.ClientID <= 0 { writeSecurityError(w, response.ParamsError, "客户端标识无效") return } + if !requireProtocolCredentialPassword(ctx, w, req, input.Password) { + return + } if err := service.RevokeClientCredential(ctx.UserID, input.ClientID); err != nil { writeSecurityError(w, response.ParamsError, err.Error()) return @@ -104,11 +143,7 @@ func RevealClientCredentials(ctx *context.Context, w http.ResponseWriter, req *h writeSecurityError(w, response.ParamsError, "请输入当前账号密码进行安全验证") return } - if _, err := auth.AuthenticateUserByID(ctx.UserID, input.Password); err != nil { - if writePasswordBusy(w, err) { - return - } - writeSecurityError(w, response.ParamsError, "密码验证失败") + if !requireProtocolCredentialPassword(ctx, w, req, input.Password) { return } codes, err := service.RevealClientCredentials(ctx.UserID) diff --git a/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.js b/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.js index 779d1b2..7c85b04 100644 --- a/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.js +++ b/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.js @@ -19,12 +19,23 @@ export function usePage() { // 新增客户端弹窗 const createDialogVisible = ref(false) const newClientName = ref('') + const createPassword = ref('') const createLoading = ref(false) - // 密码验证查看弹窗 + // 查看、重置及撤销均使用本人密码验证,操作目标在打开弹窗时固定。 const verifyDialogVisible = ref(false) const verifyPassword = ref('') const verifyLoading = ref(false) + const verifyAction = ref('reveal') + const verificationClient = ref(null) + const verifyActionLabel = computed(() => ({ + reveal: '查看明文', + regenerate: '重新生成', + revoke: '撤销授权码', + }[verifyAction.value])) + const verifyDescription = computed(() => verificationClient.value + ? `请验证本人登录密码,以${verifyActionLabel.value}:${verificationClient.value.client_name}` + : '查看客户端授权码前,请验证本人登录密码。') const currentClient = computed(() => { if (!selectedClientId.value || !Array.isArray(clientList.value)) return null @@ -129,25 +140,35 @@ export function usePage() { } const openCreateDialog = () => { + if (loading.value || createLoading.value || verifyLoading.value) return newClientName.value = '' + createPassword.value = '' createDialogVisible.value = true } const closeCreateDialog = () => { createDialogVisible.value = false newClientName.value = '' + createPassword.value = '' } const submitCreateClient = async () => { + if (createLoading.value) return const name = newClientName.value.trim() if (!name) { notify('请输入客户端名称') return } + const password = createPassword.value + if (!password) { + notify('请输入当前账号登录密码') + return + } createLoading.value = true try { const response = await http.post('/api/v1/security/credentials/clients/create', { client_name: name, + password, }) if (response.errorNo !== 0) { throw new Error(response.errorMsg || '创建客户端专属授权码失败') @@ -166,12 +187,13 @@ export function usePage() { } catch (error) { notify(error?.message || '创建客户端授权码失败') } finally { + createPassword.value = '' createLoading.value = false } } const regenerateClientCode = async (client) => { - if (!client) return + if (!client || loading.value || verifyLoading.value) return try { await ElMessageBox.confirm( `确定重新生成客户端「${client.client_name}」的专属授权码吗?旧授权码将立即失效。`, @@ -186,28 +208,11 @@ export function usePage() { return } - loading.value = true - try { - const response = await http.post('/api/v1/security/credentials/clients/regenerate', { - client_id: client.id, - }) - if (response.errorNo !== 0) { - throw new Error(response.errorMsg || '重新生成客户端授权码失败') - } - const newCode = response.data?.code || '' - revealedCodes[client.id] = newCode - startRevealCountdown(60) - notify(`客户端「${client.client_name}」专属授权码已重新生成(60秒内有效显示)`, 'success') - await loadClientCredentials() - } catch (error) { - notify(error?.message || '重新生成失败') - } finally { - loading.value = false - } + openActionVerification('regenerate', client) } const revokeClientCode = async (client) => { - if (!client) return + if (!client || loading.value || verifyLoading.value) return try { await ElMessageBox.confirm( `确定撤销并删除客户端「${client.client_name}」吗?撤销后该设备将无法通过授权码登录收发信。`, @@ -222,22 +227,7 @@ export function usePage() { return } - loading.value = true - try { - const response = await http.post('/api/v1/security/credentials/clients/revoke', { - client_id: client.id, - }) - if (response.errorNo !== 0) { - throw new Error(response.errorMsg || '撤销客户端失败') - } - delete revealedCodes[client.id] - notify(`客户端「${client.client_name}」已成功撤销`, 'success') - await loadClientCredentials() - } catch (error) { - notify(error?.message || '撤销失败') - } finally { - loading.value = false - } + openActionVerification('revoke', client) } const copyText = async (text, label = '内容') => { @@ -267,34 +257,64 @@ export function usePage() { await copyText(codeText, '专属授权码') } - const openVerifyDialog = () => { + const openActionVerification = (action, client = null) => { + if (verifyLoading.value) return + hideCodesAutomatically(true) + verifyAction.value = action + verificationClient.value = client ? { id: client.id, client_name: client.client_name } : null verifyPassword.value = '' verifyDialogVisible.value = true } + const openVerifyDialog = () => openActionVerification('reveal') + const closeVerifyDialog = () => { verifyDialogVisible.value = false verifyPassword.value = '' + verificationClient.value = null } const submitVerifyAndReveal = async () => { - const password = verifyPassword.value.trim() + if (verifyLoading.value) return + const password = verifyPassword.value if (!password) { notify('请输入当前账号登录密码') return } + const action = verifyAction.value + const client = verificationClient.value + if (action !== 'reveal' && !client) return verifyLoading.value = true try { - const response = await http.post('/api/v1/security/credentials/clients/reveal', { + const paths = { + reveal: '/api/v1/security/credentials/clients/reveal', + regenerate: '/api/v1/security/credentials/clients/regenerate', + revoke: '/api/v1/security/credentials/clients/revoke', + } + const response = await http.post(paths[action], { password, + ...(client ? { client_id: client.id } : {}), }) if (response.errorNo !== 0) { - throw new Error(response.errorMsg || '密码验证失败,无法查看授权码') + throw new Error(response.errorMsg || '安全验证或授权码操作失败') + } + closeVerifyDialog() + if (action === 'regenerate') { + revealedCodes[client.id] = response.data?.code || '' + startRevealCountdown(60) + notify(`客户端「${client.client_name}」授权码已重新生成,旧授权码已失效`, 'success') + await loadClientCredentials() + return + } + if (action === 'revoke') { + hideCodesAutomatically(true) + notify(`客户端「${client.client_name}」已成功撤销`, 'success') + await loadClientCredentials() + return } const map = response.data || {} Object.keys(revealedCodes).forEach(k => delete revealedCodes[k]) Object.assign(revealedCodes, map) - closeVerifyDialog() const count = Object.keys(map).length if (count > 0) { startRevealCountdown(60) @@ -305,6 +325,7 @@ export function usePage() { } catch (error) { notify(error?.message || '验证失败,请检查密码是否正确') } finally { + verifyPassword.value = '' verifyLoading.value = false } } @@ -315,7 +336,9 @@ export function usePage() { }) onUnmounted(() => { - clearCountdownTimer() + hideCodesAutomatically(true) + createPassword.value = '' + verifyPassword.value = '' }) return { @@ -329,10 +352,13 @@ export function usePage() { revealCountdown, createDialogVisible, newClientName, + createPassword, createLoading, verifyDialogVisible, verifyPassword, verifyLoading, + verifyActionLabel, + verifyDescription, handleClientSelect, openCreateDialog, closeCreateDialog, diff --git a/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.vue b/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.vue index 907252c..14930d6 100644 --- a/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.vue +++ b/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.vue @@ -264,6 +264,8 @@ title="新增客户端专属授权码" width="min(440px, calc(100vw - 32px))" :close-on-click-modal="false" + :close-on-press-escape="!createLoading" + :show-close="!createLoading" class="compact-dialog" @close="closeCreateDialog" > @@ -279,23 +281,32 @@ maxlength="32" show-word-limit autocomplete="off" - @keyup.enter="submitCreateClient" + /> + + + @@ -307,12 +318,14 @@ title="安全身份验证" width="min(440px, calc(100vw - 32px))" :close-on-click-modal="false" + :close-on-press-escape="!verifyLoading" + :show-close="!verifyLoading" class="compact-dialog" @close="closeVerifyDialog" >

- 查看已保存的客户端专属授权码明文前,请先验证当前账号的登录密码: + {{ verifyDescription }}

@@ -322,23 +335,23 @@ show-password placeholder="请输入当前账号登录密码" autocomplete="current-password" - @keyup.enter="submitVerifyAndReveal" + :disabled="verifyLoading" />
@@ -360,10 +373,13 @@ const { revealCountdown, createDialogVisible, newClientName, + createPassword, createLoading, verifyDialogVisible, verifyPassword, verifyLoading, + verifyActionLabel, + verifyDescription, handleClientSelect, openCreateDialog, closeCreateDialog, diff --git a/test/review/protocol-frontend.test.mjs b/test/review/protocol-frontend.test.mjs index bb7d2e4..5b0e3d1 100644 --- a/test/review/protocol-frontend.test.mjs +++ b/test/review/protocol-frontend.test.mjs @@ -31,3 +31,18 @@ test('客户端授权码接口与界面能力保持一致', () => { assert.match(template, /自动隐藏/) assert.match(template, /撤销删除/) }) + +test('客户端凭据操作验证本人密码且保留密码空白字符', () => { + const source = read('js') + const template = read('vue') + assert.match(template, /v-model="createPassword"/) + assert.match(template, /验证并生成/) + assert.match(source, /const password = createPassword\.value/) + assert.match(source, /const password = verifyPassword\.value/) + assert.doesNotMatch(source, /(?:createPassword|verifyPassword)\.value\.trim\(/) + assert.match(source, /client_name: name,\s+password,/) + assert.match(source, /openActionVerification\('regenerate', client\)/) + assert.match(source, /openActionVerification\('revoke', client\)/) + assert.match(source, /if \(verifyLoading\.value\) return/) + assert.match(source, /finally \{\s+verifyPassword\.value = ''/) +})