This commit is contained in:
parent
77abc51a9d
commit
659d98b589
73
backend/controllers/reauth.go
Normal file
73
backend/controllers/reauth.go
Normal file
@ -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)
|
||||
}
|
||||
144
backend/controllers/reauth_test.go
Normal file
144
backend/controllers/reauth_test.go
Normal file
@ -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
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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))))
|
||||
|
||||
@ -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 != "" {
|
||||
|
||||
@ -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 {
|
||||
|
||||
49
frontend/src/utils/recentAuthentication.js
Normal file
49
frontend/src/utils/recentAuthentication.js
Normal file
@ -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()
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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')
|
||||
|
||||
Loading…
Reference in New Issue
Block a user