From 659d98b589f47722850633b4a565234341d0f34d Mon Sep 17 00:00:00 2001 From: smail Date: Mon, 7 Sep 2026 17:13:06 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E9=85=8D=E7=BD=AE=E9=A1=B5?= =?UTF-8?q?=E5=B0=B1=E5=9C=B0=E8=BA=AB=E4=BB=BD=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/controllers/reauth.go | 73 +++++++++ backend/controllers/reauth_test.go | 144 ++++++++++++++++++ backend/listen/http_server/api_v1_compat.go | 1 + backend/listen/http_server/http_server.go | 1 + backend/services/auth/auth.go | 5 + backend/session/user.go | 28 ++++ frontend/src/utils/recentAuthentication.js | 49 ++++++ .../ConfigSettings/ConfigSettings.js | 18 ++- .../SpamBlockSettings/SpamBlockSettings.js | 18 ++- test/frontend/config-settings.test.mjs | 102 ++++++++++++- 10 files changed, 426 insertions(+), 13 deletions(-) create mode 100644 backend/controllers/reauth.go create mode 100644 backend/controllers/reauth_test.go create mode 100644 frontend/src/utils/recentAuthentication.js diff --git a/backend/controllers/reauth.go b/backend/controllers/reauth.go new file mode 100644 index 0000000..04f35d6 --- /dev/null +++ b/backend/controllers/reauth.go @@ -0,0 +1,73 @@ +package controllers + +import ( + "net/http" + "strconv" + + "git.pcux.cn/xuans/Smail/dto/response" + "git.pcux.cn/xuans/Smail/services/auth" + "git.pcux.cn/xuans/Smail/session" + appcontext "git.pcux.cn/xuans/Smail/utils/context" + log "github.com/sirupsen/logrus" +) + +const ( + reauthUserFailureLimit = 10 + reauthIPFailureLimit = 50 +) + +var ( + reauthUserAttempts = newLoginLimiter(reauthUserFailureLimit) + reauthIPAttempts = newLoginLimiter(reauthIPFailureLimit) +) + +type reauthRequest struct { + Password string `json:"password"` +} + +// ReauthenticateCurrentUser 在不离开当前页面的情况下重新确认当前会话用户密码。 +func ReauthenticateCurrentUser(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) { + if ctx == nil || ctx.UserID <= 0 || session.Instance == nil { + writeReauthError(w, http.StatusUnauthorized, "REAUTH_SESSION_REQUIRED", "登录状态已失效,请重新登录") + return + } + + var input reauthRequest + if err := decodeJSONRequest(w, req, &input); err != nil || input.Password == "" { + writeReauthError(w, http.StatusBadRequest, "REAUTH_REQUEST_INVALID", "请输入当前密码") + return + } + defer func() { input.Password = "" }() + + userKey := reauthUserAttempts.accountKey(strconv.Itoa(ctx.UserID)) + ipKey := reauthIPAttempts.ipKey(req) + if reauthUserAttempts.blocked(userKey) || reauthIPAttempts.blocked(ipKey) { + log.WithContext(ctx).Info("reauthentication rejected by rate limit") + writeReauthError(w, http.StatusTooManyRequests, "REAUTH_RATE_LIMITED", "尝试次数过多,请稍后再试") + return + } + + user, err := auth.AuthenticateUserByID(ctx.UserID, input.Password) + if err != nil { + reauthUserAttempts.recordFailure(userKey) + reauthIPAttempts.recordFailure(ipKey) + log.WithContext(ctx).Info("reauthentication failed") + writeReauthError(w, http.StatusUnauthorized, "REAUTH_FAILED", "当前密码不正确") + return + } + if err := session.RefreshRecentAuthentication(req.Context(), user); err != nil { + log.WithContext(ctx).WithError(err).Warn("refresh recent authentication failed") + writeReauthError(w, http.StatusUnauthorized, "REAUTH_SESSION_CHANGED", "登录状态已变化,请重新登录") + return + } + + reauthUserAttempts.clear(userKey) + response.NewSuccessResponse(map[string]any{"reauthenticated": true}).FPrint(w) +} + +func writeReauthError(w http.ResponseWriter, status int, code string, message string) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + response.NewErrorResponse(response.ConfigErrorNumber(status), message, response.ConfigErrorData{Code: code}).FPrint(w) +} diff --git a/backend/controllers/reauth_test.go b/backend/controllers/reauth_test.go new file mode 100644 index 0000000..f3ed9d5 --- /dev/null +++ b/backend/controllers/reauth_test.go @@ -0,0 +1,144 @@ +package controllers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/db" + "git.pcux.cn/xuans/Smail/dto/response" + "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/services/auth" + "git.pcux.cn/xuans/Smail/session" + appcontext "git.pcux.cn/xuans/Smail/utils/context" + "github.com/alexedwards/scs/v2" +) + +func TestReauthenticateCurrentUser(t *testing.T) { + oldConfig := config.Instance + oldDB := db.Instance + oldSession := session.Instance + oldUserAttempts := reauthUserAttempts + oldIPAttempts := reauthIPAttempts + t.Cleanup(func() { + if db.Instance != nil && db.Instance != oldDB { + _ = db.Instance.Close() + } + config.Instance = oldConfig + db.Instance = oldDB + session.Instance = oldSession + reauthUserAttempts = oldUserAttempts + reauthIPAttempts = oldIPAttempts + }) + + config.Instance = &config.Config{DbType: config.DBTypeSQLite, DbDSN: filepath.Join(t.TempDir(), "reauth.db")} + if err := db.Init(""); err != nil { + t.Fatal(err) + } + user := models.User{Account: "reauth-user", Name: "续验用户", SessionVersion: 1, SecurityVersion: 1} + if err := auth.SetPassword(&user, "CorrectP12"); err != nil { + t.Fatal(err) + } + if _, err := db.Instance.Insert(&user); err != nil { + t.Fatal(err) + } + + reauthUserAttempts = newLoginLimiter(reauthUserFailureLimit) + reauthIPAttempts = newLoginLimiter(reauthIPFailureLimit) + manager := scs.New() + loaded, err := manager.Load(httptest.NewRequest(http.MethodGet, "/", nil).Context(), "") + if err != nil { + t.Fatal(err) + } + session.Instance = manager + if err := session.PutUser(loaded, &user); err != nil { + t.Fatal(err) + } + identity, err := session.GetUser(loaded) + if err != nil { + t.Fatal(err) + } + identity.AuthenticatedAt = time.Now().Add(-time.Hour).Unix() + encoded, err := json.Marshal(identity) + if err != nil { + t.Fatal(err) + } + manager.Put(loaded, "user", string(encoded)) + ctx := &appcontext.Context{Context: loaded, UserID: user.ID, UserAccount: user.Account} + + status, failed := invokeReauth(t, ctx, `{"password":"WrongPass12"}`) + if status != http.StatusUnauthorized || failed.ErrorNo == 0 { + t.Fatalf("incorrect password response: status=%d body=%+v", status, failed) + } + unchanged, err := session.GetUser(loaded) + if err != nil || unchanged.AuthenticatedAt != identity.AuthenticatedAt { + t.Fatalf("failed reauthentication changed session: identity=%+v err=%v", unchanged, err) + } + + status, succeeded := invokeReauth(t, ctx, `{"password":"CorrectP12"}`) + if status != http.StatusOK || succeeded.ErrorNo != 0 { + t.Fatalf("correct password response: status=%d body=%+v", status, succeeded) + } + refreshed, err := session.GetUser(loaded) + if err != nil || !refreshed.HasRecentAuthentication(time.Now().UTC()) { + t.Fatalf("recent authentication was not refreshed: identity=%+v err=%v", refreshed, err) + } + + if _, err := db.Instance.ID(user.ID).Cols("disabled").Update(&models.User{Disabled: 1}); err != nil { + t.Fatal(err) + } + status, disabled := invokeReauth(t, ctx, `{"password":"CorrectP12"}`) + if status != http.StatusUnauthorized || disabled.ErrorNo == 0 { + t.Fatalf("disabled user was reauthenticated: status=%d body=%+v", status, disabled) + } +} + +func TestReauthenticateCurrentUserRequiresSessionAndRateLimitsFailures(t *testing.T) { + oldUserAttempts := reauthUserAttempts + oldIPAttempts := reauthIPAttempts + oldSession := session.Instance + t.Cleanup(func() { + reauthUserAttempts = oldUserAttempts + reauthIPAttempts = oldIPAttempts + session.Instance = oldSession + }) + + request := httptest.NewRequest(http.MethodPost, "/api/v1/security/reauth", strings.NewReader(`{"password":"AnyPass12"}`)) + recorder := httptest.NewRecorder() + ReauthenticateCurrentUser(&appcontext.Context{}, recorder, request) + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("missing session status = %d", recorder.Code) + } + + reauthUserAttempts = newLoginLimiter(1) + reauthIPAttempts = newLoginLimiter(10) + reauthUserAttempts.recordFailure(reauthUserAttempts.accountKey("7")) + ctx := &appcontext.Context{UserID: 7, UserAccount: "limited"} + request = httptest.NewRequest(http.MethodPost, "/api/v1/security/reauth", strings.NewReader(`{"password":"AnyPass12"}`)) + request.RemoteAddr = "192.0.2.50:1234" + recorder = httptest.NewRecorder() + session.Instance = scs.New() + ReauthenticateCurrentUser(ctx, recorder, request) + if recorder.Code != http.StatusTooManyRequests || !strings.Contains(recorder.Body.String(), "REAUTH_RATE_LIMITED") { + t.Fatalf("rate limit response: status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func invokeReauth(t *testing.T, ctx *appcontext.Context, body string) (int, response.Response) { + t.Helper() + request := httptest.NewRequest(http.MethodPost, "/api/v1/security/reauth", strings.NewReader(body)) + request.RemoteAddr = "192.0.2.10:1234" + request = request.WithContext(ctx.Context) + recorder := httptest.NewRecorder() + ReauthenticateCurrentUser(ctx, recorder, request) + var result response.Response + if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil { + t.Fatalf("invalid response: %v body=%s", err, recorder.Body.String()) + } + return recorder.Code, result +} diff --git a/backend/listen/http_server/api_v1_compat.go b/backend/listen/http_server/api_v1_compat.go index ab19881..4b248e7 100644 --- a/backend/listen/http_server/api_v1_compat.go +++ b/backend/listen/http_server/api_v1_compat.go @@ -13,6 +13,7 @@ import ( func registerV1Routes(mux *http.ServeMux) { registerV1MethodRoute(mux, "/api/v1/security/setup/acknowledge", "/api/security/setup/acknowledge", http.MethodPost, 4<<10, controllers.AcknowledgeSecuritySetup) registerV1MethodRoute(mux, "/api/v1/login", "/api/login", http.MethodPost, 64<<10, controllers.Login) + registerV1MethodRoute(mux, "/api/v1/security/reauth", "/api/security/reauth", http.MethodPost, 4<<10, controllers.ReauthenticateCurrentUser) registerV1MethodRoute(mux, "/api/v1/security/verification/request", "/api/security/verification/request", http.MethodPost, 16<<10, controllers.RequestVerification) registerV1MethodRoute(mux, "/api/v1/security/verification/verify", "/api/security/verification/verify", http.MethodPost, 16<<10, controllers.VerifyVerification) registerV1MethodRoute(mux, "/api/v1/security/login/verify", "/api/security/login/verify", http.MethodPost, 16<<10, controllers.CompleteLoginVerification) diff --git a/backend/listen/http_server/http_server.go b/backend/listen/http_server/http_server.go index adf925b..45c5248 100644 --- a/backend/listen/http_server/http_server.go +++ b/backend/listen/http_server/http_server.go @@ -46,6 +46,7 @@ func router(mux *http.ServeMux) error { mux.HandleFunc("/.well-known/", controllers.AcmeChallenge) mux.HandleFunc("/api/ping", controllers.Ping) mux.HandleFunc("/api/login", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.Login)))) + mux.HandleFunc("/api/security/reauth", onlyMethod(http.MethodPost, limitBody(4<<10, contextIterceptor(controllers.ReauthenticateCurrentUser)))) mux.HandleFunc("/api/security/verification/request", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.RequestVerification)))) mux.HandleFunc("/api/security/verification/verify", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.VerifyVerification)))) mux.HandleFunc("/api/security/login/verify", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.CompleteLoginVerification)))) diff --git a/backend/services/auth/auth.go b/backend/services/auth/auth.go index f2523dd..782adf4 100644 --- a/backend/services/auth/auth.go +++ b/backend/services/auth/auth.go @@ -48,6 +48,11 @@ func Authenticate(account, plain string) (*models.User, error) { return authenticateLoadedUser(&user, has, err, plain) } +// AuthenticateUserByID 使用当前会话用户 ID 校验密码,避免重新认证时切换账号。 +func AuthenticateUserByID(userID int, plain string) (*models.User, error) { + return authenticateUserByID(userID, plain) +} + func authenticateUserByID(userID int, plain string) (*models.User, error) { if userID <= 0 { if plain != "" { diff --git a/backend/session/user.go b/backend/session/user.go index e3f9362..88aa52c 100644 --- a/backend/session/user.go +++ b/backend/session/user.go @@ -58,6 +58,34 @@ func PutUser(ctx context.Context, user *models.User) error { return nil } +// RefreshRecentAuthentication 仅续期当前会话的高风险操作认证时间,不创建新会话。 +func RefreshRecentAuthentication(ctx context.Context, user *models.User) error { + if Instance == nil || user == nil || user.ID <= 0 { + return errors.New("session user is invalid") + } + identity, err := GetUser(ctx) + if err != nil { + return err + } + if identity.ID != user.ID || identity.Account != user.Account || + identity.SessionVersion != user.SessionVersion || + (identity.SecurityVersion > 0 && identity.SecurityVersion != user.SecurityVersion) { + return errors.New("session user changed") + } + identity.Name = user.Name + identity.Role = user.EffectiveRole() + identity.IsAdmin = user.IsAdmin + identity.SecurityVersion = user.SecurityVersion + identity.MustChangePassword = user.MustChangePassword + identity.AuthenticatedAt = time.Now().UTC().Unix() + encoded, err := json.Marshal(identity) + if err != nil { + return err + } + Instance.Put(ctx, userSessionKey, string(encoded)) + return nil +} + func GetUser(ctx context.Context) (UserIdentity, error) { var identity UserIdentity if Instance == nil { diff --git a/frontend/src/utils/recentAuthentication.js b/frontend/src/utils/recentAuthentication.js new file mode 100644 index 0000000..92c919f --- /dev/null +++ b/frontend/src/utils/recentAuthentication.js @@ -0,0 +1,49 @@ +import { ElMessageBox } from 'element-plus' +import { http } from '@/utils/axios' + +const recentAuthenticationRequired = 'RECENT_AUTH_REQUIRED' +const cancelledCode = 'RECENT_AUTH_CANCELLED' + +export function isRecentAuthenticationCancelled(error) { + return error?.code === cancelledCode +} + +export async function requestWithRecentAuthentication(request) { + try { + return await request() + } catch (error) { + if (error?.apiErrorCode !== recentAuthenticationRequired) throw error + } + + let password = '' + try { + const result = await ElMessageBox.prompt( + '为保护敏感配置,请确认当前登录密码。确认后将自动继续应用。', + '确认当前密码', + { + inputType: 'password', + inputPlaceholder: '请输入当前密码', + confirmButtonText: '确认并继续', + cancelButtonText: '取消', + closeOnClickModal: false, + inputValidator: value => String(value || '').length > 0 || '请输入当前密码' + } + ) + password = String(result.value || '') + } catch (error) { + const cancelled = new Error('已取消身份确认') + cancelled.code = cancelledCode + throw cancelled + } + + try { + const response = await http.post('/api/v1/security/reauth', { password }) + if (!response || response.errorNo !== 0) { + throw new Error(response?.errorMsg || '身份确认失败') + } + } finally { + password = '' + } + + return request() +} diff --git a/frontend/src/views/components/ConfigSettings/ConfigSettings.js b/frontend/src/views/components/ConfigSettings/ConfigSettings.js index e8da632..e572c09 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigSettings.js +++ b/frontend/src/views/components/ConfigSettings/ConfigSettings.js @@ -2,6 +2,7 @@ import { computed, onMounted, onBeforeUnmount, reactive, ref } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import { http } from '@/utils/axios' import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore' +import { isRecentAuthenticationCancelled, requestWithRecentAuthentication } from '@/utils/recentAuthentication' import { readFormConfig, updateFormConfig, validateFormConfig } from './ConfigForm/ConfigForm.js' const sections = [ @@ -566,7 +567,9 @@ export function usePage(pageKey = 'base') { working.value = 'save' try { const request = buildRequest() - const response = await http.put(`/api/v1/config/${apiSection()}`, request) + const response = await requestWithRecentAuthentication( + () => http.put(`/api/v1/config/${apiSection()}`, request) + ) notifyResponseError(response, '配置保存失败') savedGeneration.value = response.data?.generation || 0 pendingCandidate.value = { ...response.data, section: apiSection(), sameSession: true } @@ -577,6 +580,7 @@ export function usePage(pageKey = 'base') { if (!quiet) ElMessage.success('配置已保存,等待应用') return true } catch (error) { + if (isRecentAuthenticationCancelled(error)) return false ElMessage.error(error.message || '配置保存失败') return false } finally { @@ -593,10 +597,13 @@ export function usePage(pageKey = 'base') { } working.value = 'apply' try { - const response = await http.post(`/api/v1/config/${apiSection()}/apply`, { - generation: savedGeneration.value, - candidateId: pendingCandidate.value?.candidateId - }) + const response = await requestWithRecentAuthentication(() => http.post( + `/api/v1/config/${apiSection()}/apply`, + { + generation: savedGeneration.value, + candidateId: pendingCandidate.value?.candidateId + } + )) notifyResponseError(response, '配置应用失败') savedGeneration.value = 0 await Promise.all([loadStatus(), loadSection()]) @@ -604,6 +611,7 @@ export function usePage(pageKey = 'base') { ElMessage.success(successMessage) return true } catch (error) { + if (isRecentAuthenticationCancelled(error)) return false ElMessage.error(error.message || '配置应用失败') return false } finally { diff --git a/frontend/src/views/components/PluginSettings/SpamBlockSettings/SpamBlockSettings.js b/frontend/src/views/components/PluginSettings/SpamBlockSettings/SpamBlockSettings.js index a68d111..433f1ee 100644 --- a/frontend/src/views/components/PluginSettings/SpamBlockSettings/SpamBlockSettings.js +++ b/frontend/src/views/components/PluginSettings/SpamBlockSettings/SpamBlockSettings.js @@ -2,6 +2,7 @@ import { computed, onMounted, reactive, ref } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import { http } from '@/utils/axios' import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore' +import { isRecentAuthenticationCancelled, requestWithRecentAuthentication } from '@/utils/recentAuthentication' const defaultSettings = { url: '', @@ -121,7 +122,9 @@ export function useSpamBlockSettings() { if (!canWrite() || loading.value || working.value || errorMessage.value || pendingCandidate.value) return false working.value = 'save' try { - const response = await http.put('/api/v1/config/plugins', buildRequest()) + const response = await requestWithRecentAuthentication( + () => http.put('/api/v1/config/plugins', buildRequest()) + ) notifyResponseError(response, '插件配置保存失败') savedGeneration.value = Number(response.data?.generation || 0) pendingCandidate.value = { ...response.data, section: 'plugins', sameSession: true } @@ -131,6 +134,7 @@ export function useSpamBlockSettings() { if (!quiet) ElMessage.success('垃圾邮件插件配置已保存,等待应用') return true } catch (error) { + if (isRecentAuthenticationCancelled(error)) return false ElMessage.error(error.message || '插件配置保存失败') return false } finally { @@ -146,16 +150,20 @@ export function useSpamBlockSettings() { } working.value = 'apply' try { - const response = await http.post('/api/v1/config/plugins/apply', { - generation: savedGeneration.value, - candidateId: pendingCandidate.value?.candidateId - }) + const response = await requestWithRecentAuthentication(() => http.post( + '/api/v1/config/plugins/apply', + { + generation: savedGeneration.value, + candidateId: pendingCandidate.value?.candidateId + } + )) notifyResponseError(response, '插件配置应用失败') savedGeneration.value = 0 await load() ElMessage.success(successMessage) return true } catch (error) { + if (isRecentAuthenticationCancelled(error)) return false ElMessage.error(error.message || '垃圾邮件插件配置应用失败') return false } finally { diff --git a/test/frontend/config-settings.test.mjs b/test/frontend/config-settings.test.mjs index 6ef5225..44d98e7 100644 --- a/test/frontend/config-settings.test.mjs +++ b/test/frontend/config-settings.test.mjs @@ -7,12 +7,31 @@ import { computed, reactive, ref } from './vue.mjs' const formSource = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js', import.meta.url), 'utf8') const formHelpers = compileFunction(formSource.replaceAll('export function ', 'function ') + '\nreturn { readFormConfig, updateFormConfig, validateFormConfig }')() +const recentAuthenticationSource = readFileSync(new URL('../../frontend/src/utils/recentAuthentication.js', import.meta.url), 'utf8') + .replace(/^import .*$/gm, '') + .replaceAll('export function ', 'function ') +const compileRecentAuthentication = compileFunction( + recentAuthenticationSource + '\nreturn { isRecentAuthenticationCancelled, requestWithRecentAuthentication }', + ['ElMessageBox', 'http'] +) const source = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigSettings.js', import.meta.url), 'utf8') const compilePage = compileFunction( source.replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage') + '\nreturn usePage(pageKey)', - ['computed', 'onMounted', 'reactive', 'ref', 'ElMessage', 'ElMessageBox', 'http', 'useGlobalStatusStore', 'pageKey', 'onBeforeUnmount', 'readFormConfig', 'updateFormConfig', 'validateFormConfig'] + ['computed', 'onMounted', 'reactive', 'ref', 'ElMessage', 'ElMessageBox', 'http', 'useGlobalStatusStore', 'pageKey', 'onBeforeUnmount', 'readFormConfig', 'updateFormConfig', 'validateFormConfig', 'isRecentAuthenticationCancelled', 'requestWithRecentAuthentication'] ) -const createPageWithKey = (...args) => compilePage(...args, formHelpers.readFormConfig, formHelpers.updateFormConfig, formHelpers.validateFormConfig) +const createPageWithKey = (...args) => { + const http = args[6] + const messageBox = args[5] + const recentAuthentication = compileRecentAuthentication(messageBox, http) + return compilePage( + ...args, + formHelpers.readFormConfig, + formHelpers.updateFormConfig, + formHelpers.validateFormConfig, + recentAuthentication.isRecentAuthenticationCancelled, + recentAuthentication.requestWithRecentAuthentication + ) +} const createPage = (...args) => createPageWithKey(...args, 'base', () => {}) function fixture(get, pageKey = 'base', options = {}) { @@ -20,7 +39,10 @@ function fixture(get, pageKey = 'base', options = {}) { let dispose = () => {} const page = createPageWithKey(computed, () => {}, reactive, ref, { error: message => messages.push(message), warning: message => messages.push(message), success: () => {} }, - { confirm: options.confirm || (async () => {}) }, { + { + confirm: options.confirm || (async () => {}), + prompt: options.prompt || (async () => { throw 'cancel' }) + }, { get: path => path.endsWith('/pending') && !options.pendingGet ? Promise.resolve({ errorNo: 0, data: null }) : get(path), ...options.http }, @@ -465,6 +487,80 @@ test('保存并应用一次点击按候选协议连续完成保存和应用', as assert.equal(page.savedGeneration.value, 0) }) +test('最近认证过期时在当前页确认密码并自动重试保存和应用', async () => { + const requests = [] + let pending = null + let saveAttempts = 0 + let prompts = 0 + const active = { + listener: { port: 25, starttlsPort: 587, tlsPort: 465 }, + relay: { relayHost: '', relayPort: 0, security: '', from: '', fromDomain: '' }, + imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: {} + } + const recentAuthError = Object.assign(new Error('需要最近认证'), { apiErrorCode: 'RECENT_AUTH_REQUIRED' }) + const { page } = fixture(async () => ({ errorNo: 0, data: active }), 'mail', { + prompt: async () => { prompts++; return { value: 'CurrentPass12' } }, + http: { + get: async path => ({ + errorNo: 0, + data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: 1 } : active + }), + put: async (path, body) => { + requests.push(path) + saveAttempts++ + if (saveAttempts === 1) throw recentAuthError + pending = { generation: 2, candidateId: 'reauth-candidate', section: 'mail', sameSession: true, payload: body.payload } + return { errorNo: 0, data: pending } + }, + post: async (path, body) => { + requests.push(path) + if (path.endsWith('/reauth')) { + assert.deepEqual(body, { password: 'CurrentPass12' }) + return { errorNo: 0, data: { reauthenticated: true } } + } + pending = null + return { errorNo: 0, data: { generation: 2 } } + } + } + }) + await page.loadAll() + page.updateField(['listener', 'port'], 2525) + await page.saveAndApply() + assert.equal(prompts, 1) + assert.deepEqual(requests, [ + '/api/v1/config/mail', + '/api/v1/security/reauth', + '/api/v1/config/mail', + '/api/v1/config/mail/apply' + ]) +}) + +test('取消最近身份确认时保留配置输入且不重试请求', async () => { + let saves = 0 + const active = { + listener: { port: 25, starttlsPort: 587, tlsPort: 465 }, + relay: {}, imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: {} + } + const { page, messages } = fixture(async path => ({ + errorNo: 0, + data: path.endsWith('/status') ? { activeGeneration: 1 } : active + }), 'mail', { + prompt: async () => { throw 'cancel' }, + http: { + put: async () => { + saves++ + throw Object.assign(new Error('需要最近认证'), { apiErrorCode: 'RECENT_AUTH_REQUIRED' }) + } + } + }) + await page.loadAll() + page.updateField(['listener', 'port'], 2525) + await page.saveAndApply() + assert.equal(saves, 1) + assert.equal(page.formConfig.value.listener.port, 2525) + assert.deepEqual(messages, []) +}) + test('配置编辑区移除填写标题、说明与重复标签,保留主标题和必要提示', () => { const root = new URL('../../frontend/src/views/components/ConfigSettings/', import.meta.url) const template = readFileSync(new URL('ConfigEditor/ConfigEditor.vue', root), 'utf8')