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: `