修复第三方登录旧响应覆盖新会话并补充跨标签回归
This commit is contained in:
1 parent
30415593a6
commit
c3467875c6
6 files changed
+484
-25
No files matched your search
@@ -133,6 +133,7 @@ var fe = {
|
|||||||
W = null,
|
W = null,
|
||||||
G = null,
|
G = null,
|
||||||
K = 0,
|
K = 0,
|
||||||
|
loginAttempt = null,
|
||||||
q = null;
|
q = null;
|
||||||
function J() {
|
function J() {
|
||||||
W !== null && (clearInterval(W), (W = null));
|
W !== null && (clearInterval(W), (W = null));
|
||||||
@@ -154,6 +155,8 @@ var fe = {
|
|||||||
// 只在桌面展示区域申请新二维码,并核对图片、ticket 与 token 的完整性。
|
// 只在桌面展示区域申请新二维码,并核对图片、ticket 与 token 的完整性。
|
||||||
if (!k.value || L.value) return;
|
if (!k.value || L.value) return;
|
||||||
let e = ++K;
|
let e = ++K;
|
||||||
|
const attempt = C.prepareLogin();
|
||||||
|
loginAttempt = attempt;
|
||||||
(Z(),
|
(Z(),
|
||||||
(z.value = ``),
|
(z.value = ``),
|
||||||
(R.value = !1),
|
(R.value = !1),
|
||||||
@@ -165,7 +168,7 @@ var fe = {
|
|||||||
let t = await E.get(`/mp_weixin_notice/qrcode`, void 0, !1, {
|
let t = await E.get(`/mp_weixin_notice/qrcode`, void 0, !1, {
|
||||||
timeoutMs: 15e3,
|
timeoutMs: 15e3,
|
||||||
});
|
});
|
||||||
if (e !== K) return;
|
if (e !== K || !attempt.isCurrent()) return;
|
||||||
if (
|
if (
|
||||||
typeof t?.img_url != `string` ||
|
typeof t?.img_url != `string` ||
|
||||||
!t.img_url ||
|
!t.img_url ||
|
||||||
@@ -177,7 +180,7 @@ var fe = {
|
|||||||
throw Error(`二维码加载失败,请重新获取`);
|
throw Error(`二维码加载失败,请重新获取`);
|
||||||
((I.value = t), (z.value = ``), Ee());
|
((I.value = t), (z.value = ``), Ee());
|
||||||
} catch (t) {
|
} catch (t) {
|
||||||
if (e !== K) return;
|
if (e !== K || !attempt.isCurrent()) return;
|
||||||
(J(),
|
(J(),
|
||||||
Y(),
|
Y(),
|
||||||
(z.value = loginErrorMessage(t, `二维码加载失败,请稍后重试`)));
|
(z.value = loginErrorMessage(t, `二维码加载失败,请稍后重试`)));
|
||||||
@@ -187,7 +190,7 @@ var fe = {
|
|||||||
}
|
}
|
||||||
async function De() {
|
async function De() {
|
||||||
// 同一代次最多一个查询在途;Wait/Success/Expired/SelectClient/WaitBind 分别处理。
|
// 同一代次最多一个查询在途;Wait/Success/Expired/SelectClient/WaitBind 分别处理。
|
||||||
if (!I.value || q === K) return;
|
if (!I.value || q === K || !loginAttempt?.isCurrent()) return;
|
||||||
let e = K,
|
let e = K,
|
||||||
t = I.value;
|
t = I.value;
|
||||||
q = e;
|
q = e;
|
||||||
@@ -203,7 +206,7 @@ var fe = {
|
|||||||
timeoutMs: 1e4,
|
timeoutMs: 1e4,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (e !== K) return;
|
if (e !== K || !loginAttempt.isCurrent()) return;
|
||||||
if (!n || typeof n.status != `string`)
|
if (!n || typeof n.status != `string`)
|
||||||
throw Error(`扫码登录状态异常,请重新获取二维码`);
|
throw Error(`扫码登录状态异常,请重新获取二维码`);
|
||||||
if (n.status === `Wait`) return;
|
if (n.status === `Wait`) return;
|
||||||
@@ -252,7 +255,7 @@ var fe = {
|
|||||||
}
|
}
|
||||||
(Z(), (z.value = `扫码登录状态异常,请重新获取二维码`));
|
(Z(), (z.value = `扫码登录状态异常,请重新获取二维码`));
|
||||||
} catch (t) {
|
} catch (t) {
|
||||||
if (e !== K) return;
|
if (e !== K || !loginAttempt.isCurrent()) return;
|
||||||
(Z(),
|
(Z(),
|
||||||
(z.value = loginErrorMessage(
|
(z.value = loginErrorMessage(
|
||||||
t,
|
t,
|
||||||
@@ -270,6 +273,7 @@ var fe = {
|
|||||||
(K++,
|
(K++,
|
||||||
Z(),
|
Z(),
|
||||||
(I.value = null),
|
(I.value = null),
|
||||||
|
(loginAttempt = null),
|
||||||
(L.value = !1),
|
(L.value = !1),
|
||||||
(H.value = !1),
|
(H.value = !1),
|
||||||
(R.value = !1),
|
(R.value = !1),
|
||||||
@@ -283,11 +287,13 @@ var fe = {
|
|||||||
z.value = `登录响应无效,请重新扫码;若持续失败,请联系客服。`;
|
z.value = `登录响应无效,请重新扫码;若持续失败,请联系客服。`;
|
||||||
return;
|
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() {
|
async function Oe() {
|
||||||
// 多账号扫码只能提交当前返回列表中明确可登录的账号,且请求绑定当前二维码代次。
|
// 多账号扫码只能提交当前返回列表中明确可登录的账号,且请求绑定当前二维码代次。
|
||||||
if (H.value) return;
|
if (H.value || !loginAttempt?.isCurrent()) return;
|
||||||
if (
|
if (
|
||||||
!I.value ||
|
!I.value ||
|
||||||
!B.value.some((e) => e.id === V.value && e.status === 1)
|
!B.value.some((e) => e.id === V.value && e.status === 1)
|
||||||
@@ -310,12 +316,12 @@ var fe = {
|
|||||||
timeoutMs: 15e3,
|
timeoutMs: 15e3,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (e !== K) return;
|
if (e !== K || !loginAttempt.isCurrent()) return;
|
||||||
t?.jwt
|
t?.jwt
|
||||||
? $(t.jwt)
|
? $(t.jwt)
|
||||||
: (z.value = `登录响应无效,请重新扫码;若持续失败,请联系客服。`);
|
: (z.value = `登录响应无效,请重新扫码;若持续失败,请联系客服。`);
|
||||||
} catch (t) {
|
} catch (t) {
|
||||||
if (e !== K) return;
|
if (e !== K || !loginAttempt.isCurrent()) return;
|
||||||
z.value = loginErrorMessage(t);
|
z.value = loginErrorMessage(t);
|
||||||
} finally {
|
} finally {
|
||||||
e === K && (H.value = !1);
|
e === K && (H.value = !1);
|
||||||
@@ -329,6 +335,7 @@ var fe = {
|
|||||||
await D().catch(() => {}));
|
await D().catch(() => {}));
|
||||||
}),
|
}),
|
||||||
watch(N, Q),
|
watch(N, Q),
|
||||||
|
watch(() => C.token, Q),
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
(A?.removeEventListener(`change`, j), Q());
|
(A?.removeEventListener(`change`, j), Q());
|
||||||
}),
|
}),
|
||||||
|
|||||||
+22
-5
@@ -695,6 +695,22 @@ const useAuth = defineStore("auth", () => {
|
|||||||
localStorage.setItem("jwt", jwt);
|
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")) {
|
function prepareLegacyLogin(jwt = localStorage.getItem("jwt")) {
|
||||||
clear();
|
clear();
|
||||||
@@ -726,6 +742,7 @@ const useAuth = defineStore("auth", () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (session.promise) return session.promise;
|
if (session.promise) return session.promise;
|
||||||
|
const loginAttempt = prepareLogin();
|
||||||
const apiBase = String(useRuntimeConfig().public.apiBase ?? "");
|
const apiBase = String(useRuntimeConfig().public.apiBase ?? "");
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
session.controller = controller;
|
session.controller = controller;
|
||||||
@@ -764,7 +781,7 @@ const useAuth = defineStore("auth", () => {
|
|||||||
account.id <= 0
|
account.id <= 0
|
||||||
)
|
)
|
||||||
throw Error("暂时无法验证登录身份,请稍后重试。");
|
throw Error("暂时无法验证登录身份,请稍后重试。");
|
||||||
loginWithJwt(session.jwt);
|
loginAttempt.commit(session.jwt);
|
||||||
overview.value = account;
|
overview.value = account;
|
||||||
},
|
},
|
||||||
15_000,
|
15_000,
|
||||||
@@ -800,7 +817,7 @@ const useAuth = defineStore("auth", () => {
|
|||||||
if (type === "code" && account.includes("@"))
|
if (type === "code" && account.includes("@"))
|
||||||
throw Error("验证码登录仅支持手机号");
|
throw Error("验证码登录仅支持手机号");
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
const version = sessionVersion;
|
const loginAttempt = prepareLogin();
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const result = await api.post(
|
const result = await api.post(
|
||||||
@@ -819,11 +836,11 @@ const useAuth = defineStore("auth", () => {
|
|||||||
false,
|
false,
|
||||||
{ timeoutMs: 15_000 },
|
{ timeoutMs: 15_000 },
|
||||||
);
|
);
|
||||||
if (version !== sessionVersion) throw Error("登录状态已变化,请重试");
|
if (!loginAttempt.isCurrent()) throw Error("登录状态已变化,请重试");
|
||||||
ft(result);
|
ft(result);
|
||||||
if (!ut(result?.jwt))
|
if (!ut(result?.jwt))
|
||||||
throw Error("登录响应无效,请重新登录;若持续失败,请联系客服。");
|
throw Error("登录响应无效,请重新登录;若持续失败,请联系客服。");
|
||||||
loginWithJwt(result.jwt);
|
loginAttempt.commit(result.jwt);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -886,7 +903,7 @@ const useAuth = defineStore("auth", () => {
|
|||||||
loading,
|
loading,
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
login,
|
login,
|
||||||
loginWithJwt,
|
prepareLogin,
|
||||||
fetchOverview,
|
fetchOverview,
|
||||||
refreshOverview,
|
refreshOverview,
|
||||||
logout,
|
logout,
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ var ve = {
|
|||||||
H = ref(``),
|
H = ref(``),
|
||||||
U = ref(!1),
|
U = ref(!1),
|
||||||
W = ref(!1),
|
W = ref(!1),
|
||||||
|
loginAttempt = null,
|
||||||
G = !0;
|
G = !0;
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
G = !1;
|
G = !1;
|
||||||
@@ -127,6 +128,8 @@ var ve = {
|
|||||||
async function K() {
|
async function K() {
|
||||||
if (!W.value && G) {
|
if (!W.value && G) {
|
||||||
((W.value = !0), (U.value = !1), (H.value = ``));
|
((W.value = !0), (U.value = !1), (H.value = ``));
|
||||||
|
const attempt = A.prepareLogin();
|
||||||
|
loginAttempt = attempt;
|
||||||
try {
|
try {
|
||||||
if (N) {
|
if (N) {
|
||||||
if (!P) throw Error(`扫码会话已过期,请返回登录重新扫码`);
|
if (!P) throw Error(`扫码会话已过期,请返回登录重新扫码`);
|
||||||
@@ -134,10 +137,10 @@ var ve = {
|
|||||||
ticket: P.ticket,
|
ticket: P.ticket,
|
||||||
token: P.token,
|
token: P.token,
|
||||||
});
|
});
|
||||||
if (!G) return;
|
if (!G || !attempt.isCurrent()) return;
|
||||||
if (e.status === `Success` && isValidJwt(e.jwt)) {
|
if (e.status === `Success` && isValidJwt(e.jwt)) {
|
||||||
(wechat_binding_t(sessionStorage),
|
(wechat_binding_t(sessionStorage),
|
||||||
A.loginWithJwt(e.jwt),
|
attempt.commit(e.jwt),
|
||||||
await navigateTo(F));
|
await navigateTo(F));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -147,7 +150,7 @@ var ve = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let t = await S.get(`/oauth/token`);
|
let t = await S.get(`/oauth/token`);
|
||||||
if (!G) return;
|
if (!G || !attempt.isCurrent()) return;
|
||||||
let n = oauth_n(
|
let n = oauth_n(
|
||||||
t,
|
t,
|
||||||
window.location.origin,
|
window.location.origin,
|
||||||
@@ -159,7 +162,7 @@ var ve = {
|
|||||||
).origin,
|
).origin,
|
||||||
);
|
);
|
||||||
if (n.kind === `login`) {
|
if (n.kind === `login`) {
|
||||||
(A.loginWithJwt(n.jwt), await navigateTo(`/`));
|
(attempt.commit(n.jwt), await navigateTo(`/`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (n.kind !== `bind`)
|
if (n.kind !== `bind`)
|
||||||
@@ -167,6 +170,7 @@ var ve = {
|
|||||||
U.value = !0;
|
U.value = !0;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
G &&
|
G &&
|
||||||
|
attempt.isCurrent() &&
|
||||||
(H.value = e instanceof Error ? e.message : `验证授权会话失败`);
|
(H.value = e instanceof Error ? e.message : `验证授权会话失败`);
|
||||||
} finally {
|
} finally {
|
||||||
G && (W.value = !1);
|
G && (W.value = !1);
|
||||||
@@ -210,7 +214,8 @@ var ve = {
|
|||||||
// 必须先验证授权会话并同意协议;提交验证码绑定,微信额外携带扫码 ticket/token,成功响应的 JWT 合法后才建立会话。
|
// 必须先验证授权会话并同意协议;提交验证码绑定,微信额外携带扫码 ticket/token,成功响应的 JWT 合法后才建立会话。
|
||||||
async function Le() {
|
async function Le() {
|
||||||
if (!V.value && G) {
|
if (!V.value && G) {
|
||||||
if (((H.value = ``), !U.value)) {
|
if (((H.value = ``), !U.value || !loginAttempt.isCurrent())) {
|
||||||
|
U.value = !1;
|
||||||
H.value = `请先验证授权会话;会话失效时请返回登录重新授权`;
|
H.value = `请先验证授权会话;会话失效时请返回登录重新授权`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -223,6 +228,7 @@ var ve = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
V.value = !0;
|
V.value = !0;
|
||||||
|
const attempt = loginAttempt;
|
||||||
try {
|
try {
|
||||||
let e = q.validationError?.();
|
let e = q.validationError?.();
|
||||||
if (e) throw Error(e);
|
if (e) throw Error(e);
|
||||||
@@ -244,14 +250,15 @@ var ve = {
|
|||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!G) return;
|
if (!G || !attempt.isCurrent()) return;
|
||||||
if (!isValidJwt(t?.jwt))
|
if (!isValidJwt(t?.jwt))
|
||||||
throw Error(`绑定会话已失效,请返回登录后重新发起第三方授权`);
|
throw Error(`绑定会话已失效,请返回登录后重新发起第三方授权`);
|
||||||
(N && wechat_binding_t(sessionStorage),
|
(N && wechat_binding_t(sessionStorage),
|
||||||
A.loginWithJwt(t.jwt),
|
attempt.commit(t.jwt),
|
||||||
await navigateTo(F));
|
await navigateTo(F));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
G &&
|
G &&
|
||||||
|
attempt.isCurrent() &&
|
||||||
((H.value =
|
((H.value =
|
||||||
e instanceof Error ? e.message : `绑定失败,请重试`),
|
e instanceof Error ? e.message : `绑定失败,请重试`),
|
||||||
await q.handleError?.(e));
|
await q.handleError?.(e));
|
||||||
|
|||||||
@@ -54,9 +54,10 @@ var w = {
|
|||||||
async function A() {
|
async function A() {
|
||||||
if (k && !D.value) {
|
if (k && !D.value) {
|
||||||
((D.value = !0), (O.value = ``));
|
((D.value = !0), (O.value = ``));
|
||||||
|
const loginAttempt = E.prepareLogin();
|
||||||
try {
|
try {
|
||||||
let t = await T.get(`/oauth/token`);
|
let t = await T.get(`/oauth/token`);
|
||||||
if (!k) return;
|
if (!k || !loginAttempt.isCurrent()) return;
|
||||||
let n = oauth_n(
|
let n = oauth_n(
|
||||||
t,
|
t,
|
||||||
window.location.origin,
|
window.location.origin,
|
||||||
@@ -68,7 +69,7 @@ var w = {
|
|||||||
).origin,
|
).origin,
|
||||||
);
|
);
|
||||||
if (n.kind === `login`) {
|
if (n.kind === `login`) {
|
||||||
E.loginWithJwt(n.jwt);
|
loginAttempt.commit(n.jwt);
|
||||||
try {
|
try {
|
||||||
sessionStorage.removeItem(`console-oauth-pending`);
|
sessionStorage.removeItem(`console-oauth-pending`);
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -79,7 +80,9 @@ var w = {
|
|||||||
window.location.replace(n.url);
|
window.location.replace(n.url);
|
||||||
} else throw Error(`授权会话已过期,请返回登录并重新授权`);
|
} else throw Error(`授权会话已过期,请返回登录并重新授权`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
k && (O.value = e instanceof Error ? e.message : `恢复登录失败`);
|
k &&
|
||||||
|
loginAttempt.isCurrent() &&
|
||||||
|
(O.value = e instanceof Error ? e.message : `恢复登录失败`);
|
||||||
} finally {
|
} finally {
|
||||||
k && (D.value = !1);
|
k && (D.value = !1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default defineNuxtPlugin({
|
|||||||
// Read the latest value: several storage events may already be queued.
|
// Read the latest value: several storage events may already be queued.
|
||||||
const persisted = localStorage.getItem("jwt");
|
const persisted = localStorage.getItem("jwt");
|
||||||
if (persisted === auth.token) return;
|
if (persisted === auth.token) return;
|
||||||
if (isValidJwt(persisted)) auth.loginWithJwt(persisted);
|
if (isValidJwt(persisted)) auth.prepareLogin().commit(persisted);
|
||||||
else auth.clear();
|
else auth.clear();
|
||||||
const route = router.currentRoute.value;
|
const route = router.currentRoute.value;
|
||||||
if (!auth.isLoggedIn && !isPublicRoute(route)) {
|
if (!auth.isLoggedIn && !isPublicRoute(route)) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user