This commit is contained in:
parent
5523d9b327
commit
e358396be5
@ -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)
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 = ''/)
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user