加固客户端凭据签发与撤销的本人密码验证
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-10-02 20:46:24 +08:00
parent 5523d9b327
commit e358396be5
6 changed files with 286 additions and 68 deletions

View File

@ -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)

View File

@ -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")
}
}

View File

@ -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)

View File

@ -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,

View File

@ -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"
/>
</el-form-item>
<el-form-item label="登录密码">
<el-input
v-model="createPassword"
type="password"
show-password
placeholder="验证本人密码后生成授权码"
autocomplete="current-password"
:disabled="createLoading"
/>
</el-form-item>
</el-form>
</div>
<template #footer>
<div class="dialog-footer">
<el-button size="default" @click="closeCreateDialog">取消</el-button>
<el-button size="default" :disabled="createLoading" @click="closeCreateDialog">取消</el-button>
<el-button
type="primary"
size="default"
class="btn-primary-action"
:loading="createLoading"
:disabled="!newClientName.trim()"
:disabled="!newClientName.trim() || !createPassword || createLoading"
@click="submitCreateClient"
>
立即生成
验证并生成
</el-button>
</div>
</template>
@ -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"
>
<div class="dialog-body">
<p class="dialog-tip">
查看已保存的客户端专属授权码明文前,请先验证当前账号的登录密码:
{{ verifyDescription }}
</p>
<el-form @submit.prevent="submitVerifyAndReveal">
<el-form-item label="登录密码">
@ -322,23 +335,23 @@
show-password
placeholder="请输入当前账号登录密码"
autocomplete="current-password"
@keyup.enter="submitVerifyAndReveal"
:disabled="verifyLoading"
/>
</el-form-item>
</el-form>
</div>
<template #footer>
<div class="dialog-footer">
<el-button size="default" @click="closeVerifyDialog">取消</el-button>
<el-button size="default" :disabled="verifyLoading" @click="closeVerifyDialog">取消</el-button>
<el-button
type="primary"
size="default"
class="btn-primary-action"
:loading="verifyLoading"
:disabled="!verifyPassword.trim()"
:disabled="!verifyPassword || verifyLoading"
@click="submitVerifyAndReveal"
>
验证并查看明文
验证并{{ verifyActionLabel }}
</el-button>
</div>
</template>
@ -360,10 +373,13 @@ const {
revealCountdown,
createDialogVisible,
newClientName,
createPassword,
createLoading,
verifyDialogVisible,
verifyPassword,
verifyLoading,
verifyActionLabel,
verifyDescription,
handleClientSelect,
openCreateDialog,
closeCreateDialog,

View File

@ -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 = ''/)
})