diff --git a/app/components/AuthProviders.vue b/app/components/AuthProviders.vue index 5e2ac0e..e539bf2 100644 --- a/app/components/AuthProviders.vue +++ b/app/components/AuthProviders.vue @@ -123,6 +123,7 @@ var fe = { (e.preventDefault(), (N.value = t[r]), F.value[N.value]?.focus()); } let I = ref(null), + imageReady = ref(!1), L = ref(!1), R = ref(!1), z = ref(``), @@ -158,6 +159,8 @@ var fe = { const attempt = C.prepareLogin(); loginAttempt = attempt; (Z(), + (I.value = null), + (imageReady.value = !1), (z.value = ``), (R.value = !1), (B.value = []), @@ -178,7 +181,7 @@ var fe = { !t.token ) throw Error(`二维码加载失败,请重新获取`); - ((I.value = t), (z.value = ``), Ee()); + ((I.value = t), (z.value = ``)); } catch (t) { if (e !== K || !attempt.isCurrent()) return; (J(), @@ -188,6 +191,26 @@ var fe = { e === K && (L.value = !1); } } + // 接口返回地址并不代表图片可扫码;只有当前图片解码成功才启动登录轮询。 + function qrImageLoaded(generation) { + if ( + generation !== K || + L.value || + imageReady.value || + !I.value || + !loginAttempt?.isCurrent() + ) + return; + ((imageReady.value = !0), Ee()); + } + function qrImageFailed(generation) { + if (generation !== K || !I.value || !loginAttempt?.isCurrent()) + return; + // 图片失败废弃整个扫码代次,迟到的状态响应不能再登录或转入绑定。 + (Q(), + (R.value = !0), + (z.value = `二维码图片加载失败,请刷新后重试`)); + } async function De() { // 同一代次最多一个查询在途;Wait/Success/Expired/SelectClient/WaitBind 分别处理。 if (!I.value || q === K || !loginAttempt?.isCurrent()) return; @@ -273,6 +296,7 @@ var fe = { (K++, Z(), (I.value = null), + (imageReady.value = !1), (loginAttempt = null), (L.value = !1), (H.value = !1), @@ -345,7 +369,8 @@ var fe = { (n, r) => { let a = radiobutton, s = button, - u = message; + u = message, + imageGeneration = K; return unref(k) ? (openBlock(), createElementBlock(`aside`, fe, [ @@ -454,7 +479,9 @@ var fe = { "qr-box-idle": !unref(I), }, ]), - "aria-busy": unref(L), + "aria-busy": + unref(L) || + (!!unref(I) && !unref(imageReady)), }, [ unref(I) && !unref(R) @@ -462,7 +489,7 @@ var fe = { createElementBlock( `img`, { - key: 0, + key: imageGeneration, class: normalizeClass([ `qr-img`, { @@ -471,6 +498,14 @@ var fe = { ]), src: unref(I).img_url, alt: `微信扫码登录`, + onLoad: () => + qrImageLoaded( + imageGeneration, + ), + onError: () => + qrImageFailed( + imageGeneration, + ), }, null, 10, @@ -535,7 +570,8 @@ var fe = { `p`, ye, toDisplayString( - unref(L) && !unref(I) + unref(L) || + (unref(I) && !unref(imageReady)) ? `二维码加载中…` : unref(B).length ? `选择账号后继续登录` diff --git a/app/pages/account/realname/index.vue b/app/pages/account/realname/index.vue index 35fb18b..bebe381 100644 --- a/app/pages/account/realname/index.vue +++ b/app/pages/account/realname/index.vue @@ -2569,6 +2569,8 @@ var xe = { { key: 1, srcdoc: unref(X), + // 认证插件只拥有自己的渲染环境,不能继承控制台身份或访问父页面。 + sandbox: `allow-scripts`, class: `auth-frame`, title: `实名认证二维码`, }, diff --git a/tests/review-auth-hardening.e2e.test.mjs b/tests/review-auth-hardening.e2e.test.mjs new file mode 100644 index 0000000..db044ab --- /dev/null +++ b/tests/review-auth-hardening.e2e.test.mjs @@ -0,0 +1,221 @@ +/** + * Failure modes recorded before the fixes: + * - Certification HTML must keep its QR-refresh script, without reading or writing the console parent. + * - Receiving a QR URL is not evidence that an image loaded; pending/broken images must not start login polling. + * - A broken image needs an accessible error and a retry, and a fresh loaded image must still permit normal login. + * All scenarios enter the actual built pages and replace only the external HTTP service or browser clock. + */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + appOrigin, + backendOrigin, + eventually, + login, + scenario, +} from "./helpers/console-browser.mjs"; + +const qrImage = "R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="; + +function reply(route, data) { + return route.fulfill({ + json: { status: 200, msg: "", data }, + headers: { "Access-Control-Allow-Origin": "*" }, + }); +} + +test( + "certification QR refresh scripts run without console-parent privileges", + { timeout: 30000 }, + (t) => + scenario(t, {}, async (page) => { + await page.addInitScript(() => { + document.addEventListener( + "DOMContentLoaded", + () => { + document.documentElement.dataset.auditIsolationSeed = + "synthetic-local-marker"; + }, + { once: true }, + ); + }); + await page + .context() + .route(`${backendOrigin}/console/v1/certification/**`, (route) => { + const path = new URL(route.request().url()).pathname; + if (path.endsWith("/info")) + return reply(route, { + certification_open: 1, + certification_company_open: 0, + certification_upload: 0, + is_certification: 0, + person: { status: 4 }, + }); + if (path.endsWith("/auth")) + return reply(route, { + html: `
`, + }); + if (path.endsWith("/status")) + return reply(route, { code: 2, refresh: 0 }); + return route.fallback(); + }); + await login(page, "/account/realname"); + await page + .getByRole("button", { name: "继续扫码核验", exact: true }) + .click(); + const element = await page.locator("iframe.auth-frame").elementHandle(); + const frame = await element.contentFrame(); + await frame + .locator('[data-certification-qr][data-audit-dynamic-qr="updated"]') + .waitFor(); + const observed = { + refreshedQrVisible: await frame + .locator("[data-certification-qr]") + .isVisible(), + pluginScriptRan: await frame.evaluate( + () => document.documentElement.dataset.auditPluginScript === "ran", + ), + parentAccessBlocked: await frame.evaluate( + () => + document.documentElement.dataset.auditParentAccess === "blocked", + ), + parentMarkerUntouched: await page.evaluate( + () => + document.documentElement.dataset.auditFromCertification === + undefined, + ), + }; + assert.equal(observed.refreshedQrVisible, true); + assert.equal(observed.pluginScriptRan, true); + assert.equal(observed.parentAccessBlocked, true); + assert.equal(observed.parentMarkerUntouched, true); + return observed; + }), +); + +test( + "a broken WeChat image stops its scan and a fresh image recovers login", + { timeout: 35000 }, + (t) => + scenario(t, {}, async (page) => { + await page.clock.install(); + let acquisitions = 0; + let firstScanPolls = 0; + let secondScanPolls = 0; + let releaseImage; + const imageRelease = new Promise((resolve) => { + releaseImage = resolve; + }); + let imageRequested; + const imageStarted = new Promise((resolve) => { + imageRequested = resolve; + }); + await page.context().route(`${backendOrigin}/**`, async (route) => { + const url = new URL(route.request().url()); + if (url.pathname === "/audit-qr/1.gif") { + imageRequested(); + await imageRelease; + return route.fulfill({ + contentType: "image/gif", + body: "invalid-image", + }); + } + if (url.pathname === "/audit-qr/2.gif") + return route.fulfill({ + contentType: "image/gif", + body: Buffer.from(qrImage, "base64"), + }); + if (url.pathname === "/console/v1/common") + return reply(route, { + website_name: "本地扫码验收", + login_phone_password: 1, + login_email_password: 1, + plugin_configuration: { mp_weixin_notice: { scan_login: 1 } }, + }); + if (url.pathname === "/console/v1/mp_weixin_notice/qrcode") { + acquisitions++; + return reply(route, { + img_url: `${backendOrigin}/audit-qr/${acquisitions}.gif`, + ticket: `local-qr-ticket-${acquisitions}`, + token: "local-qr-token", + }); + } + if (url.pathname === "/console/v1/mp_weixin_notice/qrcode/status") { + if (url.searchParams.get("ticket") === "local-qr-ticket-1") { + firstScanPolls++; + return reply(route, { status: "Wait" }); + } + secondScanPolls++; + return reply(route, { status: "Success", jwt: "e2e-session-token" }); + } + return route.fallback(); + }); + try { + await page.goto(`${appOrigin}/login?redirect=/security/ssh-keys`); + await page + .getByRole("button", { name: "获取二维码", exact: true }) + .click(); + await imageStarted; + await page.clock.runFor(5000); + const pollsBeforeImage = firstScanPolls; + releaseImage(); + await eventually( + () => + page.evaluate(() => { + const image = document.querySelector(".qr-img"); + return !image || image.complete; + }), + Boolean, + "The invalid image must finish decoding", + ); + await page.clock.runFor(5000); + const failureState = { + accessibleError: await page + .locator(".qr-status") + .getByRole("alert") + .isVisible(), + brokenImageRemoved: (await page.locator(".qr-img").count()) === 0, + pollsBeforeImage, + pollsAfterImageFailure: firstScanPolls, + }; + await page + .getByRole("button", { name: "刷新二维码", exact: true }) + .click(); + await eventually( + () => + page.evaluate(() => { + const image = document.querySelector(".qr-img"); + return Boolean(image?.complete && image.naturalWidth > 0); + }), + Boolean, + "Retry must display a real decoded QR image", + ); + await page.clock.runFor(2100); + await page.waitForURL((url) => url.pathname === "/security/ssh-keys"); + await page.getByText("audit-01", { exact: true }).waitFor(); + assert.equal(failureState.accessibleError, true); + assert.equal(failureState.brokenImageRemoved, true); + assert.equal(failureState.pollsBeforeImage, 0); + assert.equal(failureState.pollsAfterImageFailure, 0); + assert.equal(secondScanPolls, 1); + return { ...failureState, retryCompletedNormalLogin: true }; + } finally { + releaseImage(); + } + }), +);