修复通用 OAuth 弹窗登录与账号关联并隔离迟到结果

This commit is contained in:
cieanyau committed 2026-11-07 08:21:42 +08:00
1 parent d330ad01cc
commit e27f08e36e
5 files changed
+690 -78

No files matched your search

+4 -34
View File
@@ -1,11 +1,10 @@
<script>
/** 根据站点 oauth 配置显示第三方登录入口;启动跳转及错误处理复用 useOAuth。 */
/** 根据站点 oauth 配置显示第三方登录入口;弹窗授权与结果确认复用 useOAuth。 */
import "~/assets/styles/GenericOAuthProviders.Bv8oNwXP.css";
import { toDisplayString } from "vue";
import { renderList } from "vue";
import { onMounted } from "vue";
import { createBlock } from "vue";
import { ref } from "vue";
import { withCtx } from "vue";
import { createCommentVNode } from "vue";
import { computed } from "vue";
@@ -15,7 +14,6 @@ import { createTextVNode } from "vue";
import { unref } from "vue";
import { defineComponent } from "vue";
import { openBlock } from "vue";
import { NuxtLink } from "#components";
import { t as useSiteConfig_t } from "~/lib/useSiteConfig.js";
import button from "openvue/button";
import { t as _plugin_vue_export_helper_t } from "~/lib/_plugin-vue_export-helper.js";
@@ -38,22 +36,15 @@ var x = {
setup(f) {
let _ = useSiteConfig_t(),
w = computed(() => oauth_t(_.config.value?.oauth)),
{ busy: T, error: E, start: D } = useOAuth_t(),
O = ref(!1);
{ busy: T, error: E, start: D } = useOAuth_t();
return (
onMounted(() => {
_.load();
try {
// 只读取浏览器会话中的待完成提示,不将该标志当作登录身份或授权凭证。
O.value =
sessionStorage.getItem(`console-oauth-pending`) === `1`;
} catch {}
}),
(n, i) => {
let s = button,
f = message,
h = NuxtLink;
return unref(w).length || unref(O)
f = message;
return unref(w).length
? (openBlock(),
createElementBlock(`div`, x, [
unref(w).length
@@ -126,27 +117,6 @@ var x = {
},
))
: createCommentVNode(``, !0),
unref(O)
? (openBlock(),
createBlock(
h,
{
key: 2,
to: `/oauth/callback`,
},
{
default: withCtx(() => [
...(i[0] ||= [
createTextVNode(
`已完成第三方授权?继续登录`,
-1,
),
]),
]),
_: 1,
},
))
: createCommentVNode(``, !0),
]))
: createCommentVNode(``, !0);
}
+152 -40
View File
@@ -1,45 +1,157 @@
/** OAuth 授权发起器:校验提供商和返回 URL,再整页跳转;不在前端构造第三方授权参数。 */
import { onBeforeUnmount } from "vue";
import { ref } from "vue";
import { useApi } from "~/lib/auth.js";
import { r as oauth_r } from "~/lib/oauth.js";
function i() {
let i = useApi(),
a = ref(!1),
o = ref(``),
s = !0;
/** OAuth 授权由发起页持有弹窗;窗口关闭只表示授权结束,身份结果仍须向后端确认。 */
import { navigateTo, useRoute, useRuntimeConfig } from "#app";
import { onBeforeUnmount, ref } from "vue";
import { useApi, useAuth } from "~/lib/auth.js";
import { r as safeRedirect } from "~/lib/auth-redirect.js";
import {
n as parseOAuthResult,
r as authorizeUrl,
t as providers,
} from "~/lib/oauth.js";
function useOAuth() {
const api = useApi();
const auth = useAuth();
const config = useRuntimeConfig();
const route = useRoute();
const busy = ref(false);
const error = ref("");
let mounted = true;
let cancel;
onBeforeUnmount(() => {
s = !1;
mounted = false;
cancel?.();
});
// busy 防止重复发起,卸载标记防止用户离开页面后被迟到的授权响应再次跳走。
async function c(e) {
if (s && !a.value) {
if (((o.value = ``), !/^[a-z][a-z0-9_]*$/i.test(e))) {
o.value = `授权方式无效`;
return;
}
a.value = !0;
try {
let t = await i.get(`/oauth/${encodeURIComponent(e)}`);
if (!s) return;
let n = oauth_r(t?.url, window.location.origin);
if (!n) throw Error(`授权地址无效,请联系支持`);
try {
// 仅记录当前标签页曾发起授权,不存储 OAuth 凭证或 JWT。
sessionStorage.setItem(`console-oauth-pending`, `1`);
} catch {}
window.location.assign(n);
} catch (e) {
s && (o.value = e instanceof Error ? e.message : `授权失败`);
} finally {
s && (a.value = !1);
}
function start(provider) {
if (!mounted || busy.value) return Promise.resolve(false);
error.value = "";
if (typeof provider !== "string" || !/^[a-z][a-z0-9_]*$/i.test(provider)) {
error.value = "授权方式无效";
return Promise.resolve(false);
}
// 必须在点击的同步调用栈创建窗口,等待接口后再打开会触发浏览器弹窗拦截。
const popup = window.open(
"about:blank",
"_blank",
"popup,width=800,height=800",
);
if (!popup) {
error.value = "授权窗口被浏览器拦截,请允许本站弹窗后重试";
return Promise.resolve(false);
}
// 保留父页检查 closed 的句柄,同时防止外部授权页反向控制发起页。
popup.opener = null;
const attempt = auth.prepareLogin();
const linking = !!auth.token;
const redirect = safeRedirect(route.query.redirect);
const deadline = Date.now() + 10 * 60 * 1000;
busy.value = true;
return new Promise((resolve) => {
let finished = false;
let launched = false;
let consuming = false;
const isCurrent = () => mounted && !finished && attempt.isCurrent();
function finish(message = "", success = false) {
if (finished) return;
finished = true;
clearInterval(timer);
// 外站加载后 opener 已隔离,浏览器不允许父页强关;后端回调或用户自行关闭窗口。
if (!launched && !popup.closed) popup.close();
if (cancel === finish) cancel = undefined;
if (mounted) {
busy.value = false;
if (attempt.isCurrent()) error.value = message;
}
resolve(success);
}
function ensureCurrent() {
if (!isCurrent()) {
finish();
return false;
}
if (Date.now() >= deadline) {
finish("授权已超时,请关闭授权窗口后重新授权");
return false;
}
return true;
}
async function consumeResult() {
try {
const data = await api.get(linking ? "/account" : "/oauth/token");
if (!ensureCurrent()) return;
if (linking) {
// 已登录关联会清除后端 OAuth 会话,因此通过账户资料确认,不能再次消费登录令牌。
if (
!providers(data.account?.oauth).some(
(item) => item.name === provider && item.linked,
)
)
throw Error("授权未完成,请重新关联账号");
finish("", true);
return;
}
const result = parseOAuthResult(
data,
window.location.origin,
new URL(
String(config.public.apiBase || window.location.origin),
window.location.origin,
).origin,
);
if (result.kind === "login") {
attempt.commit(result.jwt);
finish("", true);
await navigateTo(redirect);
} else if (result.kind === "bind") {
const target = new URL(result.url);
if (redirect !== "/") target.searchParams.set("redirect", redirect);
finish("", true);
await navigateTo(target.pathname + target.search + target.hash);
} else {
throw Error("授权未完成或已过期,请重新授权");
}
} catch (cause) {
if (ensureCurrent())
finish(cause instanceof Error ? cause.message : "授权失败");
}
}
// 后端授权状态有效期为十分钟;离页、身份切换、取消与超时统一结束这一次授权。
const timer = setInterval(() => {
if (!ensureCurrent()) return;
if (popup.closed && !consuming) {
if (!launched) return finish();
consuming = true;
void consumeResult();
}
}, 300);
cancel = finish;
void (async () => {
try {
const data = await api.get(`/oauth/${encodeURIComponent(provider)}`);
if (!ensureCurrent()) return;
if (popup.closed) return finish();
const url = authorizeUrl(data?.url, window.location.origin);
if (!url) throw Error("授权地址无效,请联系支持");
popup.location.href = url;
launched = true;
} catch (cause) {
if (ensureCurrent())
finish(cause instanceof Error ? cause.message : "授权失败");
}
})();
});
}
return {
busy: a,
error: o,
start: c,
};
return { busy, error, start };
}
export { i as t };
export { useOAuth as t };
+9 -1
View File
@@ -95,6 +95,14 @@ var D = {
}
}
}
// 授权弹窗完成后重新读取关联列表;成功提示只属于仍然存活的当前页面。
async function connect(provider) {
B.value = ``;
if ((await K(provider)) && J) {
B.value = `已关联账号`;
await Y();
}
}
// 仅对已关联的提供方执行解绑;请求完成后重新加载列表,以服务端结果为准。
async function X() {
if (U.value?.linked && !H.value && !W.value && J) {
@@ -314,7 +322,7 @@ var D = {
label: `关联账号`,
disabled:
unref(H) || unref(W) || unref(R),
onClick: (e) => unref(K)(t.name),
onClick: (e) => connect(t.name),
},
null,
8,
-3
View File
@@ -70,9 +70,6 @@ var w = {
);
if (n.kind === `login`) {
loginAttempt.commit(n.jwt);
try {
sessionStorage.removeItem(`console-oauth-pending`);
} catch {}
await navigateTo(`/`);
} else if (n.kind === `bind`) {
if (new URL(n.url).pathname === `/oauth/callback`)