diff --git a/app/components/AuthProviders.vue b/app/components/AuthProviders.vue index 5805fc5..fbb51d6 100644 --- a/app/components/AuthProviders.vue +++ b/app/components/AuthProviders.vue @@ -133,6 +133,7 @@ var fe = { W = null, G = null, K = 0, + loginAttempt = null, q = null; function J() { W !== null && (clearInterval(W), (W = null)); @@ -154,6 +155,8 @@ var fe = { // 只在桌面展示区域申请新二维码,并核对图片、ticket 与 token 的完整性。 if (!k.value || L.value) return; let e = ++K; + const attempt = C.prepareLogin(); + loginAttempt = attempt; (Z(), (z.value = ``), (R.value = !1), @@ -165,7 +168,7 @@ var fe = { let t = await E.get(`/mp_weixin_notice/qrcode`, void 0, !1, { timeoutMs: 15e3, }); - if (e !== K) return; + if (e !== K || !attempt.isCurrent()) return; if ( typeof t?.img_url != `string` || !t.img_url || @@ -177,7 +180,7 @@ var fe = { throw Error(`二维码加载失败,请重新获取`); ((I.value = t), (z.value = ``), Ee()); } catch (t) { - if (e !== K) return; + if (e !== K || !attempt.isCurrent()) return; (J(), Y(), (z.value = loginErrorMessage(t, `二维码加载失败,请稍后重试`))); @@ -187,7 +190,7 @@ var fe = { } async function De() { // 同一代次最多一个查询在途;Wait/Success/Expired/SelectClient/WaitBind 分别处理。 - if (!I.value || q === K) return; + if (!I.value || q === K || !loginAttempt?.isCurrent()) return; let e = K, t = I.value; q = e; @@ -203,7 +206,7 @@ var fe = { timeoutMs: 1e4, }, ); - if (e !== K) return; + if (e !== K || !loginAttempt.isCurrent()) return; if (!n || typeof n.status != `string`) throw Error(`扫码登录状态异常,请重新获取二维码`); if (n.status === `Wait`) return; @@ -252,7 +255,7 @@ var fe = { } (Z(), (z.value = `扫码登录状态异常,请重新获取二维码`)); } catch (t) { - if (e !== K) return; + if (e !== K || !loginAttempt.isCurrent()) return; (Z(), (z.value = loginErrorMessage( t, @@ -270,6 +273,7 @@ var fe = { (K++, Z(), (I.value = null), + (loginAttempt = null), (L.value = !1), (H.value = !1), (R.value = !1), @@ -283,11 +287,13 @@ var fe = { z.value = `登录响应无效,请重新扫码;若持续失败,请联系客服。`; return; } - (C.loginWithJwt(e), K++, T.push(auth_redirect_r(w.query.redirect))); + (loginAttempt.commit(e), + K++, + T.push(auth_redirect_r(w.query.redirect))); } async function Oe() { // 多账号扫码只能提交当前返回列表中明确可登录的账号,且请求绑定当前二维码代次。 - if (H.value) return; + if (H.value || !loginAttempt?.isCurrent()) return; if ( !I.value || !B.value.some((e) => e.id === V.value && e.status === 1) @@ -310,12 +316,12 @@ var fe = { timeoutMs: 15e3, }, ); - if (e !== K) return; + if (e !== K || !loginAttempt.isCurrent()) return; t?.jwt ? $(t.jwt) : (z.value = `登录响应无效,请重新扫码;若持续失败,请联系客服。`); } catch (t) { - if (e !== K) return; + if (e !== K || !loginAttempt.isCurrent()) return; z.value = loginErrorMessage(t); } finally { e === K && (H.value = !1); @@ -329,6 +335,7 @@ var fe = { await D().catch(() => {})); }), watch(N, Q), + watch(() => C.token, Q), onUnmounted(() => { (A?.removeEventListener(`change`, j), Q()); }), diff --git a/app/lib/auth.js b/app/lib/auth.js index 381ff0b..be61a5e 100644 --- a/app/lib/auth.js +++ b/app/lib/auth.js @@ -695,6 +695,22 @@ const useAuth = defineStore("auth", () => { localStorage.setItem("jwt", jwt); } + // 身份结果只归属于请求开始时的会话;也检查持久凭证,覆盖 storage 事件尚未处理的窗口。 + function prepareLogin() { + const version = sessionVersion; + const persistedToken = localStorage.getItem("jwt"); + const isCurrent = () => + version === sessionVersion && + persistedToken === localStorage.getItem("jwt"); + return { + isCurrent, + commit(jwt) { + if (!isCurrent()) throw Error("登录状态已变化,请重新发起登录。"); + loginWithJwt(jwt); + }, + }; + } + // 旧平台带来的凭证先暂存待验证,不立即写成已登录状态,避免路由守卫误放行。 function prepareLegacyLogin(jwt = localStorage.getItem("jwt")) { clear(); @@ -726,6 +742,7 @@ const useAuth = defineStore("auth", () => { ); } if (session.promise) return session.promise; + const loginAttempt = prepareLogin(); const apiBase = String(useRuntimeConfig().public.apiBase ?? ""); const controller = new AbortController(); session.controller = controller; @@ -764,7 +781,7 @@ const useAuth = defineStore("auth", () => { account.id <= 0 ) throw Error("暂时无法验证登录身份,请稍后重试。"); - loginWithJwt(session.jwt); + loginAttempt.commit(session.jwt); overview.value = account; }, 15_000, @@ -800,7 +817,7 @@ const useAuth = defineStore("auth", () => { if (type === "code" && account.includes("@")) throw Error("验证码登录仅支持手机号"); const api = useApi(); - const version = sessionVersion; + const loginAttempt = prepareLogin(); loading.value = true; try { const result = await api.post( @@ -819,11 +836,11 @@ const useAuth = defineStore("auth", () => { false, { timeoutMs: 15_000 }, ); - if (version !== sessionVersion) throw Error("登录状态已变化,请重试"); + if (!loginAttempt.isCurrent()) throw Error("登录状态已变化,请重试"); ft(result); if (!ut(result?.jwt)) throw Error("登录响应无效,请重新登录;若持续失败,请联系客服。"); - loginWithJwt(result.jwt); + loginAttempt.commit(result.jwt); } finally { loading.value = false; } @@ -886,7 +903,7 @@ const useAuth = defineStore("auth", () => { loading, isLoggedIn, login, - loginWithJwt, + prepareLogin, fetchOverview, refreshOverview, logout, diff --git a/app/pages/oauth/bind.vue b/app/pages/oauth/bind.vue index 19734d5..b1f5784 100644 --- a/app/pages/oauth/bind.vue +++ b/app/pages/oauth/bind.vue @@ -119,6 +119,7 @@ var ve = { H = ref(``), U = ref(!1), W = ref(!1), + loginAttempt = null, G = !0; onBeforeUnmount(() => { G = !1; @@ -127,6 +128,8 @@ var ve = { async function K() { if (!W.value && G) { ((W.value = !0), (U.value = !1), (H.value = ``)); + const attempt = A.prepareLogin(); + loginAttempt = attempt; try { if (N) { if (!P) throw Error(`扫码会话已过期,请返回登录重新扫码`); @@ -134,10 +137,10 @@ var ve = { ticket: P.ticket, token: P.token, }); - if (!G) return; + if (!G || !attempt.isCurrent()) return; if (e.status === `Success` && isValidJwt(e.jwt)) { (wechat_binding_t(sessionStorage), - A.loginWithJwt(e.jwt), + attempt.commit(e.jwt), await navigateTo(F)); return; } @@ -147,7 +150,7 @@ var ve = { return; } let t = await S.get(`/oauth/token`); - if (!G) return; + if (!G || !attempt.isCurrent()) return; let n = oauth_n( t, window.location.origin, @@ -159,7 +162,7 @@ var ve = { ).origin, ); if (n.kind === `login`) { - (A.loginWithJwt(n.jwt), await navigateTo(`/`)); + (attempt.commit(n.jwt), await navigateTo(`/`)); return; } if (n.kind !== `bind`) @@ -167,6 +170,7 @@ var ve = { U.value = !0; } catch (e) { G && + attempt.isCurrent() && (H.value = e instanceof Error ? e.message : `验证授权会话失败`); } finally { G && (W.value = !1); @@ -210,7 +214,8 @@ var ve = { // 必须先验证授权会话并同意协议;提交验证码绑定,微信额外携带扫码 ticket/token,成功响应的 JWT 合法后才建立会话。 async function Le() { if (!V.value && G) { - if (((H.value = ``), !U.value)) { + if (((H.value = ``), !U.value || !loginAttempt.isCurrent())) { + U.value = !1; H.value = `请先验证授权会话;会话失效时请返回登录重新授权`; return; } @@ -223,6 +228,7 @@ var ve = { return; } V.value = !0; + const attempt = loginAttempt; try { let e = q.validationError?.(); if (e) throw Error(e); @@ -244,14 +250,15 @@ var ve = { : {}), }, ); - if (!G) return; + if (!G || !attempt.isCurrent()) return; if (!isValidJwt(t?.jwt)) throw Error(`绑定会话已失效,请返回登录后重新发起第三方授权`); (N && wechat_binding_t(sessionStorage), - A.loginWithJwt(t.jwt), + attempt.commit(t.jwt), await navigateTo(F)); } catch (e) { G && + attempt.isCurrent() && ((H.value = e instanceof Error ? e.message : `绑定失败,请重试`), await q.handleError?.(e)); diff --git a/app/pages/oauth/callback.vue b/app/pages/oauth/callback.vue index 5cb8f87..e67f3d3 100644 --- a/app/pages/oauth/callback.vue +++ b/app/pages/oauth/callback.vue @@ -54,9 +54,10 @@ var w = { async function A() { if (k && !D.value) { ((D.value = !0), (O.value = ``)); + const loginAttempt = E.prepareLogin(); try { let t = await T.get(`/oauth/token`); - if (!k) return; + if (!k || !loginAttempt.isCurrent()) return; let n = oauth_n( t, window.location.origin, @@ -68,7 +69,7 @@ var w = { ).origin, ); if (n.kind === `login`) { - E.loginWithJwt(n.jwt); + loginAttempt.commit(n.jwt); try { sessionStorage.removeItem(`console-oauth-pending`); } catch {} @@ -79,7 +80,9 @@ var w = { window.location.replace(n.url); } else throw Error(`授权会话已过期,请返回登录并重新授权`); } catch (e) { - k && (O.value = e instanceof Error ? e.message : `恢复登录失败`); + k && + loginAttempt.isCurrent() && + (O.value = e instanceof Error ? e.message : `恢复登录失败`); } finally { k && (D.value = !1); } diff --git a/app/plugins/03-session.client.js b/app/plugins/03-session.client.js index ee8f215..cac6c8e 100644 --- a/app/plugins/03-session.client.js +++ b/app/plugins/03-session.client.js @@ -20,7 +20,7 @@ export default defineNuxtPlugin({ // Read the latest value: several storage events may already be queued. const persisted = localStorage.getItem("jwt"); if (persisted === auth.token) return; - if (isValidJwt(persisted)) auth.loginWithJwt(persisted); + if (isValidJwt(persisted)) auth.prepareLogin().commit(persisted); else auth.clear(); const route = router.currentRoute.value; if (!auth.isLoggedIn && !isPublicRoute(route)) { diff --git a/tests/review-oauth-session.e2e.test.mjs b/tests/review-oauth-session.e2e.test.mjs new file mode 100644 index 0000000..97c7987 --- /dev/null +++ b/tests/review-oauth-session.e2e.test.mjs @@ -0,0 +1,425 @@ +/** 登录结果的会话归属:真实页面、真实跨标签 storage 事件和受控外部 HTTP 响应。 */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + appOrigin, + backendOrigin, + eventually, + login, + scenario, +} from "./helpers/console-browser.mjs"; + +function deferred() { + let resolve; + const promise = new Promise((done) => (resolve = done)); + return { promise, resolve }; +} + +async function respond(route, data, status = 200, msg = "") { + return route.fulfill({ + json: { status, msg, data }, + headers: { "Access-Control-Allow-Origin": "*" }, + }); +} + +async function oauthBoundary(page, handle) { + // 只声明 OAuth 的外部协议;普通登录、账户和列表仍使用独立 HTTP 假后端。 + await page + .context() + .route(`${backendOrigin}/console/v1/**`, async (route) => { + const path = new URL(route.request().url()).pathname.replace( + /^\/console\/v1/, + "", + ); + if (await handle(route, path)) return; + // 基线复现若错误接纳旧会话,让该外部账号的真实首页继续加载,不以 401 掩盖覆盖。 + const previousAccount = { + "/index": { + account: { id: 99, username: "older-oauth-account", credit: "0.00" }, + }, + "/menu": { menu: [] }, + "/client_care/mail": { credit_limit: { count: 0, list: [], type: [] } }, + "/certification/info": {}, + "/host": { list: [], count: 0 }, + "/ticket": { list: [], count: 0 }, + "/ticket/status": { list: [] }, + "/ticket/statistic": {}, + "/announcement": { list: [], count: 0 }, + "/recommend/promoter": {}, + "/transaction": { list: [], count: 0 }, + "/withdraw/rule/credit": {}, + "/ssh_key": { list: [], count: 0 }, + }; + if ( + route.request().headers().authorization === + "Bearer test-older-oauth-session" && + Object.hasOwn(previousAccount, path) + ) + return respond(route, previousAccount[path]); + return route.fallback(); + }); +} + +async function changeSession(page, transition) { + // 不预置 token,也不手工派发 storage;另一个真实标签完成登录和可选的退出。 + await page.evaluate(() => { + window.oauthSessionTransitions = []; + window.addEventListener("storage", (event) => { + if (event.key === "jwt") + window.oauthSessionTransitions.push( + event.newValue ? "login" : "logout", + ); + }); + }); + const other = await page.context().newPage(); + await login(other); + await other.getByText("audit-01", { exact: true }).waitFor(); + await eventually( + () => page.evaluate(() => window.oauthSessionTransitions), + (events) => events.includes("login"), + "New login must synchronize before the pending OAuth result is released", + ); + if (transition === "logout") { + await other.getByRole("button", { name: "用户菜单", exact: true }).click(); + await other + .getByRole("menuitem", { name: "退出登录", exact: true }) + .click(); + await other.waitForURL((url) => url.pathname === "/login"); + await eventually( + () => page.evaluate(() => window.oauthSessionTransitions), + (events) => events.includes("logout"), + "Logout must synchronize before the pending OAuth result is released", + ); + } + return other; +} + +async function sessionIsPreserved(page, transition) { + assert.equal( + await page.evaluate( + (loggedOut) => + localStorage.getItem("jwt") === + (loggedOut ? null : "e2e-session-token"), + transition === "logout", + ), + true, + "An older result must preserve the session established by the newer action", + ); +} + +test( + "OAuth callback establishes a current session normally", + { timeout: 25000 }, + (t) => + scenario(t, {}, async (page) => { + await oauthBoundary(page, async (route, path) => { + if (path !== "/oauth/token") return false; + await respond(route, { jwt: "e2e-session-token" }); + return true; + }); + await page.goto(`${appOrigin}/oauth/callback`); + await page.waitForURL((url) => url.pathname === "/"); + await page + .getByRole("button", { name: "用户菜单", exact: true }) + .waitFor(); + await sessionIsPreserved(page, "login"); + return { currentOAuthSessionAccepted: true, memberHomeVisible: true }; + }), +); + +test( + "OAuth verified binding establishes a current session normally", + { timeout: 25000 }, + (t) => + scenario(t, {}, async (page) => { + await oauthBoundary(page, async (route, path) => { + if (path === "/oauth/token") { + await respond(route, { url: "/oauth/bind" }); + return true; + } + if (path === "/oauth/client/bind") { + await respond(route, { jwt: "e2e-session-token" }); + return true; + } + return false; + }); + await page.goto(`${appOrigin}/oauth/bind?redirect=/security/ssh-keys`); + await page.getByLabel("手机号", { exact: true }).fill("13800138000"); + await page.getByLabel("验证码", { exact: true }).fill("123456"); + await page.getByRole("checkbox").check(); + await page.getByRole("button", { name: "确认绑定", exact: true }).click(); + await page.waitForURL((url) => url.pathname === "/security/ssh-keys"); + await page.getByText("audit-01", { exact: true }).waitFor(); + await sessionIsPreserved(page, "login"); + return { verifiedBindingAccepted: true, protectedPageVisible: true }; + }), +); + +for (const pagePath of ["/oauth/callback", "/oauth/bind"]) { + for (const transition of ["login", "logout"]) { + for (const outcome of ["jwt", "bind", "error"]) { + test( + `${pagePath} ignores older ${outcome} after cross-tab ${transition}`, + { timeout: 25000 }, + (t) => + scenario(t, {}, async (page) => { + const started = deferred(); + const release = deferred(); + await oauthBoundary(page, async (route, path) => { + if (path !== "/oauth/token") return false; + started.resolve(); + await release.promise; + await respond( + route, + outcome === "jwt" + ? { jwt: "test-older-oauth-session" } + : { url: "/oauth/bind" }, + outcome === "error" ? 422 : 200, + outcome === "error" ? "过期授权请求失败" : "", + ); + return true; + }); + await page.goto(`${appOrigin}${pagePath}`); + await started.promise; + await changeSession(page, transition); + release.resolve(); + const retry = page.getByRole("button", { + name: + pagePath === "/oauth/callback" ? "重新验证" : "验证授权会话", + exact: true, + }); + await eventually( + () => retry.isEnabled().catch(() => false), + Boolean, + "The old request must finish without navigating or enabling its old binding flow", + ); + assert.equal(new URL(page.url()).pathname, pagePath); + assert.equal(await page.getByRole("alert").count(), 0); + assert.equal( + await page.getByLabel("手机号", { exact: true }).count(), + 0, + ); + await sessionIsPreserved(page, transition); + return { + transition, + outcome, + newerSessionPreserved: true, + noOldNavigation: true, + noOldError: true, + noOldBindingForm: true, + }; + }), + ); + } + } +} + +for (const transition of ["login", "logout"]) { + test( + `OAuth binding submission ignores its late session after cross-tab ${transition}`, + { timeout: 25000 }, + (t) => + scenario(t, {}, async (page) => { + const started = deferred(); + const release = deferred(); + await oauthBoundary(page, async (route, path) => { + if (path === "/oauth/token") { + await respond(route, { url: "/oauth/bind" }); + return true; + } + if (path === "/oauth/client/bind") { + started.resolve(); + await release.promise; + await respond(route, { jwt: "test-older-oauth-session" }); + return true; + } + return false; + }); + await page.goto(`${appOrigin}/oauth/bind?redirect=/security/ssh-keys`); + await page.getByLabel("手机号", { exact: true }).fill("13800138000"); + await page.getByLabel("验证码", { exact: true }).fill("123456"); + await page.getByRole("checkbox").check(); + const submit = page.getByRole("button", { + name: "确认绑定", + exact: true, + }); + await submit.click(); + await started.promise; + await changeSession(page, transition); + release.resolve(); + await eventually( + () => submit.isEnabled().catch(() => false), + Boolean, + "The old binding must finish without replacing the newer session", + ); + assert.equal(new URL(page.url()).pathname, "/oauth/bind"); + await sessionIsPreserved(page, transition); + return { + transition, + lateBindingIgnored: true, + newerSessionPreserved: true, + }; + }), + ); +} + +async function wechatBoundary(page, statusHandler) { + await oauthBoundary(page, async (route, path) => { + if (path === "/common") { + await respond(route, { + website_name: "本地扫码验收", + login_phone_password: 1, + login_email_password: 1, + login_phone_verify: 1, + register_phone: 1, + register_email: 1, + plugin_configuration: { mp_weixin_notice: { scan_login: 1 } }, + oauth: [], + }); + return true; + } + if (path === "/mp_weixin_notice/qrcode") { + await respond(route, { + img_url: + "data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==", + ticket: "local-qr-ticket", + token: "local-qr-token", + }); + return true; + } + return statusHandler(route, path); + }); +} + +async function requestWechat(page) { + await page.goto(`${appOrigin}/login?redirect=/security/ssh-keys`); + await page.getByRole("button", { name: "获取二维码", exact: true }).click(); + await page.getByRole("img", { name: "微信扫码登录", exact: true }).waitFor(); +} + +test( + "a current WeChat scan establishes its session normally", + { timeout: 25000 }, + (t) => + scenario(t, {}, async (page) => { + await wechatBoundary(page, async (route, path) => { + if (path !== "/mp_weixin_notice/qrcode/status") return false; + await respond(route, { status: "Success", jwt: "e2e-session-token" }); + return true; + }); + await requestWechat(page); + await page.waitForURL((url) => url.pathname === "/security/ssh-keys"); + await page.getByText("audit-01", { exact: true }).waitFor(); + await sessionIsPreserved(page, "login"); + return { currentWechatSessionAccepted: true }; + }), +); + +test( + "a current WeChat account selection establishes its session normally", + { timeout: 25000 }, + (t) => + scenario(t, {}, async (page) => { + await wechatBoundary(page, async (route, path) => { + if (path === "/mp_weixin_notice/qrcode/status") { + await respond(route, { + status: "SelectClient", + client: [{ id: 1, username: "current-qr-account", status: 1 }], + }); + return true; + } + if (path === "/mp_weixin_notice/qrcode/select_client_login") { + await respond(route, { jwt: "e2e-session-token" }); + return true; + } + return false; + }); + await requestWechat(page); + await page.locator(".qr-client-btn").click(); + await page.waitForURL((url) => url.pathname === "/security/ssh-keys"); + await page.getByText("audit-01", { exact: true }).waitFor(); + await sessionIsPreserved(page, "login"); + return { currentWechatSelectionAccepted: true }; + }), +); + +for (const outcome of ["Success", "WaitBind", "SelectClient"]) { + test( + `WeChat ${outcome} result cannot resume its old scan after a newer session`, + { timeout: 30000 }, + (t) => + scenario(t, {}, async (page) => { + const started = deferred(); + const release = deferred(); + const transition = outcome === "SelectClient" ? "logout" : "login"; + await wechatBoundary(page, async (route, path) => { + if (path === "/mp_weixin_notice/qrcode/status") { + if (outcome === "SelectClient") { + await respond(route, { + status: "SelectClient", + client: [{ id: 99, username: "older-qr-account", status: 1 }], + }); + } else { + started.resolve(); + await release.promise; + await respond(route, { + status: outcome, + ...(outcome === "Success" + ? { jwt: "test-older-oauth-session" } + : {}), + }); + } + return true; + } + if (path === "/mp_weixin_notice/qrcode/select_client_login") { + started.resolve(); + await release.promise; + await respond(route, { jwt: "test-older-oauth-session" }); + return true; + } + return false; + }); + await requestWechat(page); + if (outcome === "SelectClient") + await page.locator(".qr-client-btn").click(); + await started.promise; + await changeSession(page, transition); + const completed = page.waitForResponse((response) => + response + .url() + .endsWith( + outcome === "SelectClient" + ? "/mp_weixin_notice/qrcode/select_client_login" + : "/mp_weixin_notice/qrcode/status?ticket=local-qr-ticket&token=local-qr-token", + ), + ); + release.resolve(); + await (await completed).finished(); + // 网络完成后跨过一次实际渲染,观察迟到结果,而非在释放响应前就读取已清空的二维码。 + await page.evaluate( + () => + new Promise((done) => + requestAnimationFrame(() => requestAnimationFrame(done)), + ), + ); + await page + .getByRole("button", { name: "获取二维码", exact: true }) + .waitFor(); + assert.equal(new URL(page.url()).pathname, "/login"); + assert.equal(await page.locator(".qr-client-btn").count(), 0); + assert.equal( + await page.evaluate( + () => sessionStorage.getItem("console.wechat-binding") === null, + ), + true, + ); + await sessionIsPreserved(page, transition); + return { + outcome, + transition, + oldScanDiscarded: true, + newerSessionPreserved: true, + }; + }), + ); +}