From 3e545de8b6a075990263ea07dc24f730077f4096 Mon Sep 17 00:00:00 2001 From: smail Date: Fri, 18 Sep 2026 22:05:58 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=99=BB=E5=BD=95=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=8E=A5=E5=8F=A3=E9=81=97=E6=BC=8F=E5=B9=B6=E8=A7=A3?= =?UTF-8?q?=E9=99=A4=E9=AA=8C=E8=AF=81=E7=A0=81=E6=8F=90=E4=BA=A4=E9=98=BB?= =?UTF-8?q?=E5=A1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/controllers/login_methods_test.go | 44 +++++++++++++++++++ backend/listen/http_server/api_v1_compat.go | 1 + .../listen/http_server/api_v1_compat_test.go | 2 + .../LoginVerification/LoginVerification.js | 4 ++ .../LoginVerification/LoginVerification.vue | 3 +- test/frontend/login-methods.test.mjs | 42 +++++++++++++++--- 6 files changed, 90 insertions(+), 6 deletions(-) diff --git a/backend/controllers/login_methods_test.go b/backend/controllers/login_methods_test.go index d4757ef..4ce80cb 100644 --- a/backend/controllers/login_methods_test.go +++ b/backend/controllers/login_methods_test.go @@ -263,6 +263,50 @@ func TestLoginMethodsRolesAndSingleConsumption(t *testing.T) { } } +func TestEmailLoginAfterAsyncDelivery(t *testing.T) { + codes := make(chan string, 1) + user, _ := setupLoginMethods(t, func(_ verification.Challenge, code string) error { + codes <- code + return nil + }) + pending, err := beginLoginVerification(user, "127.0.0.1") + if err != nil { + t.Fatal(err) + } + id := pending["challenge_id"].(string) + input := resendLoginVerificationRequest{ID: id, Account: user.Account} + if code, _ := callLoginHandler(t, ResendLoginVerification, input); code != 0 { + t.Fatal("failed to start delivery") + } + var emailCode string + select { + case emailCode = <-codes: + case <-time.After(3 * time.Second): + t.Fatal("delivery did not run") + } + deadline := time.Now().Add(3 * time.Second) + for { + code, data := callLoginHandler(t, LoginVerificationStatus, input) + if code != 0 { + t.Fatal("status failed") + } + if data["delivery_status"] == "sent" { + break + } + if time.Now().After(deadline) { + t.Fatal("delivery status never became sent") + } + time.Sleep(time.Millisecond) + } + verify := loginVerificationRequest{ID: id, Account: user.Account, Method: "email", Code: emailCode} + if code, _ := callLoginHandler(t, CompleteLoginVerification, verify); code != 0 { + t.Fatal("delivered email code did not complete login") + } + if code, _ := callLoginHandler(t, CompleteLoginVerification, verify); code == 0 { + t.Fatal("consumed code accepted again") + } +} + func TestLoginMethodsRejectInvalidState(t *testing.T) { user, _ := setupLoginMethods(t, func(verification.Challenge, string) error { return nil }) pending, _ := beginLoginVerification(user, "127.0.0.1") diff --git a/backend/listen/http_server/api_v1_compat.go b/backend/listen/http_server/api_v1_compat.go index 1c8c75d..726f6a8 100644 --- a/backend/listen/http_server/api_v1_compat.go +++ b/backend/listen/http_server/api_v1_compat.go @@ -18,6 +18,7 @@ func registerV1Routes(mux *http.ServeMux) { 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) registerV1MethodRoute(mux, "/api/v1/security/login/resend", "/api/security/login/resend", http.MethodPost, 16<<10, controllers.ResendLoginVerification) + registerV1MethodRoute(mux, "/api/v1/security/login/status", "/api/security/login/status", http.MethodPost, 4<<10, controllers.LoginVerificationStatus) registerV1MethodRoute(mux, "/api/v1/security/webauthn/registration/begin", "/api/security/webauthn/registration/begin", http.MethodPost, 16<<10, controllers.BeginWebAuthnRegistration) registerV1MethodRoute(mux, "/api/v1/security/webauthn/registration/finish", "/api/security/webauthn/registration/finish", http.MethodPost, 64<<10, controllers.FinishWebAuthnRegistration) registerV1MethodRoute(mux, "/api/v1/security/webauthn/credentials", "/api/security/webauthn/credentials", http.MethodGet, 0, controllers.ListWebAuthnCredentials) diff --git a/backend/listen/http_server/api_v1_compat_test.go b/backend/listen/http_server/api_v1_compat_test.go index 09cb0d6..57596b4 100644 --- a/backend/listen/http_server/api_v1_compat_test.go +++ b/backend/listen/http_server/api_v1_compat_test.go @@ -58,6 +58,7 @@ func TestV1PostOnlyRoutesRejectGet(t *testing.T) { "/api/v1/user/info", "/api/v1/user/list", "/api/v1/rule/get", + "/api/v1/security/login/status", } { recorder := httptest.NewRecorder() mux.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, endpoint, nil)) @@ -198,6 +199,7 @@ func TestFrontendV1RoutesAreRegistered(t *testing.T) { "/api/v1/security/external-email/list", "/api/v1/security/external-email/verify", "/api/v1/security/login/resend", + "/api/v1/security/login/status", "/api/v1/security/login/verify", "/api/v1/security/recovery/begin", "/api/v1/security/recovery/complete", diff --git a/frontend/src/views/LoginVerification/LoginVerification.js b/frontend/src/views/LoginVerification/LoginVerification.js index 8e5192d..01c9210 100644 --- a/frontend/src/views/LoginVerification/LoginVerification.js +++ b/frontend/src/views/LoginVerification/LoginVerification.js @@ -26,6 +26,9 @@ export function usePage() { const expiresAt = computed(() => pending.value?.expiresAt || "") const methods = computed(() => pending.value?.methods || []) const sending = computed(() => pending.value?.deliveryStatus === "sending") + const canSubmit = computed(() => !loading.value && remainingSeconds.value > 0 + && methods.value.includes(method.value) + && (method.value === "totp" ? /^\d{6}$/ : /^\d{8}$/).test(code.value.trim())) const deliveryMessage = computed(() => { switch (pending.value?.deliveryStatus) { case "sending": return "正在发送邮件,您可以等待,也可以切换到手机动态码。" @@ -186,6 +189,7 @@ export function usePage() { method, methods, sending, + canSubmit, retrySeconds, deliveryMessage, chooseMethod, diff --git a/frontend/src/views/LoginVerification/LoginVerification.vue b/frontend/src/views/LoginVerification/LoginVerification.vue index 8107933..8fb5e0d 100644 --- a/frontend/src/views/LoginVerification/LoginVerification.vue +++ b/frontend/src/views/LoginVerification/LoginVerification.vue @@ -94,7 +94,7 @@ size="large" class="verification-submit" :loading="loading" - :disabled="remainingSeconds <= 0 || (method === 'email' && pending?.deliveryStatus !== 'sent')" + :disabled="!canSubmit" @click="verifyCode" > 验证并登录 @@ -125,6 +125,7 @@ const { method, methods, sending, + canSubmit, retrySeconds, deliveryMessage, chooseMethod, diff --git a/test/frontend/login-methods.test.mjs b/test/frontend/login-methods.test.mjs index 8548aa9..f7928c2 100644 --- a/test/frontend/login-methods.test.mjs +++ b/test/frontend/login-methods.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' import { compileFunction } from 'node:vm' -import { computed, ref } from './vue.mjs' +import { computed, reactive, ref } from './vue.mjs' const source = readFileSync(new URL('../../frontend/src/views/LoginVerification/LoginVerification.js', import.meta.url), 'utf8') const createPage = compileFunction( @@ -10,9 +10,9 @@ const createPage = compileFunction( ['computed', 'onBeforeUnmount', 'onMounted', 'ref', 'ElMessage', 'useRouter', 'useGlobalStatusStore', 'http', 'getSafeLoginRedirect', 'window'] ) -function fixture() { +function fixture({ statusFails = false } = {}) { const calls = [] - const pending = { challengeId: 'pending-id', account: 'alice', methods: ['email', 'totp'], deliveryStatus: 'idle', expiresAt: new Date(Date.now() + 600000).toISOString() } + const pending = reactive({ challengeId: 'pending-id', account: 'alice', methods: ['email', 'totp'], deliveryStatus: 'idle', expiresAt: new Date(Date.now() + 600000).toISOString() }) const store = { pendingLogin: pending, setPendingLogin: data => Object.assign(pending, { deliveryStatus: data.delivery_status, retryAt: data.retry_at }), @@ -20,17 +20,49 @@ function fixture() { setUser: () => calls.push('session') } let mount + let tick const page = createPage(computed, () => {}, callback => { mount = callback }, ref, { error() {}, warning() {} }, () => ({ replace: async () => {} }), () => store, { post: async (url, input, options) => { calls.push({ url, input, options }) + if (url.endsWith('/status')) { + if (statusFails) throw new Error('status unavailable') + return { errorNo: 0, data: { pending: true, delivery_status: 'sent' } } + } if (url.endsWith('/resend')) return { errorNo: 0, data: { pending: true, delivery_status: 'sending', retry_at: new Date(Date.now() + 60000).toISOString() } } return { errorNo: 0, data: { account: 'alice' } } - } }, () => '/', { setInterval: () => 1, clearInterval() {} }) + } }, () => '/', { setInterval: callback => { tick = callback; return 1 }, clearInterval() {} }) mount() - return { page, calls } + return { page, calls, tick: async () => { tick(); await new Promise(resolve => setImmediate(resolve)) } } } +test('email code remains submittable when delivery status polling fails', async () => { + const { page, calls, tick } = fixture({ statusFails: true }) + page.chooseMethod('email') + assert.equal(page.canSubmit.value, false) + await page.resendCode() + await tick() + assert.ok(page.errorMessage.value) + assert.equal(page.sending.value, true) + page.code.value = '12345678' + assert.equal(page.canSubmit.value, true) + await page.verifyCode() + assert.equal(calls.filter(call => call.url?.endsWith('/resend')).length, 1) + assert.ok(calls.includes('session')) + assert.equal(calls.find(call => call.url?.endsWith('/verify')).input.method, 'email') +}) + +test('polling advances email delivery to sent without sending again', async () => { + const { page, calls, tick } = fixture() + page.chooseMethod('email') + await page.resendCode() + await tick() + assert.equal(page.sending.value, false) + assert.equal(page.pending.value.deliveryStatus, 'sent') + assert.equal(page.errorMessage.value, '') + assert.equal(calls.filter(call => call.url?.endsWith('/resend')).length, 1) +}) + test('进入选择页和切换验证方式均不发信,只有点击发送按钮才请求一次', async () => { const { page, calls } = fixture() assert.deepEqual(calls, [])