This commit is contained in:
parent
8fc121220e
commit
40c6b09168
@ -4,13 +4,14 @@ import HomeHeader from '@/views/components/HomeHeader/HomeHeader.vue'
|
||||
import HomeAside from '@/views/components/HomeAside/HomeAside.vue';
|
||||
import RecoverySetupPrompt from '@/views/components/RecoverySetupPrompt/RecoverySetupPrompt.vue'
|
||||
import StartupStatus from '@/views/components/StartupStatus/StartupStatus.vue'
|
||||
import {ref, watch} from 'vue'
|
||||
import {computed, ref, watch} from 'vue'
|
||||
import {useGlobalStatusStore} from "@/stores/useGlobalStatusStore";
|
||||
|
||||
const route = useRoute()
|
||||
const pageName = ref(route.name)
|
||||
const globalStatus = useGlobalStatusStore();
|
||||
const standalonePages = ['login', 'login-verification', 'force-password-change', 'setup', 'forgot-password', 'reset-blocked']
|
||||
const showStartupStatus = computed(() => globalStatus.startupBlocked && pageName.value !== 'setup')
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
@ -22,7 +23,7 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<StartupStatus v-if="globalStatus.startupBlocked" />
|
||||
<StartupStatus v-if="showStartupStatus" />
|
||||
<div v-else id="main">
|
||||
<HomeHeader v-if="!standalonePages.includes(pageName)"/>
|
||||
<div id="content">
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { createRouter, createWebHashHistory } from "vue-router";
|
||||
import {useGlobalStatusStore} from "@/stores/useGlobalStatusStore";
|
||||
import {AUTH_STATUS} from "@/utils/auth.mjs";
|
||||
import {createLoginLocation} from "@/utils/navigation.mjs";
|
||||
import {createLoginLocation, createSetupLocation} from "@/utils/navigation.mjs";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
@ -80,6 +80,11 @@ router.beforeEach(async (to) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (needsAuthentication && authStatus === AUTH_STATUS.NEEDS_SETUP) {
|
||||
globalStatus.setAuthRetryPath(to.fullPath);
|
||||
return createSetupLocation(to.fullPath);
|
||||
}
|
||||
|
||||
if (!needsAuthentication && authStatus === AUTH_STATUS.UNAVAILABLE) {
|
||||
globalStatus.resetAuthAvailability();
|
||||
authStatus = AUTH_STATUS.UNKNOWN;
|
||||
|
||||
@ -55,14 +55,18 @@ const useGlobalStatusStore = defineStore("useGlobalStatusStore", {
|
||||
this.setUser(response.data, false);
|
||||
} else if (result === AUTH_STATUS.UNAUTHENTICATED) {
|
||||
this.clear();
|
||||
} else if (result === AUTH_STATUS.NEEDS_SETUP) {
|
||||
this.markSetupRequired();
|
||||
} else {
|
||||
this.markAuthUnavailable();
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
const result = classifyAuthenticationError(error);
|
||||
const result = classifyAuthenticationError(error, {authenticationRequest: true});
|
||||
if (result === AUTH_STATUS.UNAUTHENTICATED) {
|
||||
this.clear();
|
||||
} else if (result === AUTH_STATUS.NEEDS_SETUP) {
|
||||
this.markSetupRequired();
|
||||
} else {
|
||||
this.markAuthUnavailable();
|
||||
}
|
||||
@ -103,6 +107,14 @@ const useGlobalStatusStore = defineStore("useGlobalStatusStore", {
|
||||
this.authStatus = AUTH_STATUS.UNAVAILABLE;
|
||||
this.authError = "认证服务暂时不可用,请检查网络后重试";
|
||||
},
|
||||
markSetupRequired() {
|
||||
this.userInfos = {};
|
||||
this.pendingLogin = null;
|
||||
this.recoveryPromptDismissed = false;
|
||||
this.authChecked = true;
|
||||
this.authStatus = AUTH_STATUS.NEEDS_SETUP;
|
||||
this.authError = "";
|
||||
},
|
||||
resetAuthAvailability() {
|
||||
this.authChecked = false;
|
||||
this.authStatus = AUTH_STATUS.UNKNOWN;
|
||||
|
||||
@ -3,12 +3,16 @@ const AUTH_STATUS = Object.freeze({
|
||||
LOADING: "loading",
|
||||
AUTHENTICATED: "authenticated",
|
||||
UNAUTHENTICATED: "unauthenticated",
|
||||
NEEDS_SETUP: "needs_setup",
|
||||
UNAVAILABLE: "unavailable"
|
||||
});
|
||||
|
||||
let authRefreshPromise = null;
|
||||
|
||||
function classifyAuthenticationResponse(response) {
|
||||
if (response?.errorNo === 402) {
|
||||
return AUTH_STATUS.NEEDS_SETUP;
|
||||
}
|
||||
if (response?.errorNo === 0 && response.data && typeof response.data === "object") {
|
||||
return AUTH_STATUS.AUTHENTICATED;
|
||||
}
|
||||
@ -18,8 +22,12 @@ function classifyAuthenticationResponse(response) {
|
||||
return AUTH_STATUS.UNAVAILABLE;
|
||||
}
|
||||
|
||||
function classifyAuthenticationError(error) {
|
||||
if (error?.response?.status === 403 || error?.response?.data?.errorNo === 403) {
|
||||
function classifyAuthenticationError(error, options = {}) {
|
||||
if (error?.response?.data?.errorNo === 402 || error?.response?.status === 402) {
|
||||
return AUTH_STATUS.NEEDS_SETUP;
|
||||
}
|
||||
if (error?.response?.data?.errorNo === 403
|
||||
|| (options.authenticationRequest && error?.response?.status === 403)) {
|
||||
return AUTH_STATUS.UNAUTHENTICATED;
|
||||
}
|
||||
return AUTH_STATUS.UNAVAILABLE;
|
||||
|
||||
@ -55,20 +55,29 @@ function attachApiErrorDetails(error) {
|
||||
return error
|
||||
}
|
||||
|
||||
function isSetupRequest(config) {
|
||||
function getRequestPath(config) {
|
||||
const rawUrl = String(config?.url || '').split('?')[0]
|
||||
let path = rawUrl
|
||||
try {
|
||||
path = new URL(rawUrl, config?.baseURL || window.location.origin).pathname
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost'
|
||||
return new URL(rawUrl, config?.baseURL || origin).pathname
|
||||
} catch (error) {
|
||||
path = rawUrl
|
||||
return rawUrl
|
||||
}
|
||||
}
|
||||
|
||||
function isSetupRequest(config) {
|
||||
const path = getRequestPath(config)
|
||||
return path === '/api/setup' || path === '/api/v1/setup' || path === '/__smail_setup_bootstrap'
|
||||
}
|
||||
|
||||
function isAuthenticationRefreshRequest(config) {
|
||||
const rawUrl = String(config?.url || '').split('?')[0]
|
||||
return rawUrl === '/api/user/info' || rawUrl === '/api/v1/user/info'
|
||||
const path = getRequestPath(config)
|
||||
return path === '/api/user/info' || path === '/api/v1/user/info'
|
||||
}
|
||||
|
||||
function isAuthenticationFailureError(error) {
|
||||
return error?.response?.data?.errorNo === 403
|
||||
|| (error?.response?.status === 403 && isAuthenticationRefreshRequest(error.config))
|
||||
}
|
||||
|
||||
http.interceptors.response.use(async (response) => {
|
||||
@ -78,15 +87,6 @@ http.interceptors.response.use(async (response) => {
|
||||
&& !isAuthenticationRefreshRequest(response.config)) {
|
||||
await handleLoginRedirect();
|
||||
}
|
||||
//响应成功
|
||||
if (response.data?.errorNo === 402 && !isSetupRequest(response.config)) {
|
||||
await router.replace({
|
||||
path: '/setup',
|
||||
query: {
|
||||
redirect: router.currentRoute.value.fullPath
|
||||
}
|
||||
});
|
||||
}
|
||||
return response.data;
|
||||
}, async (error) => {
|
||||
//响应错误
|
||||
@ -104,8 +104,10 @@ http.interceptors.response.use(async (response) => {
|
||||
message = lang.setup_failed
|
||||
} else if (isAuthenticationRefreshRequest(error.config)) {
|
||||
message = '登录状态已失效'
|
||||
} else {
|
||||
} else if (isAuthenticationFailureError(error)) {
|
||||
await handleLoginRedirect();
|
||||
} else {
|
||||
message = '无权访问'
|
||||
}
|
||||
break;
|
||||
case 404:
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
const BLOCKED_REDIRECT_PATHS = ["/login", "/login-verification"];
|
||||
const BLOCKED_REDIRECT_PATHS = ["/login", "/login-verification", "/setup"];
|
||||
|
||||
function decodeRedirect(value) {
|
||||
let decoded = value;
|
||||
@ -52,6 +52,15 @@ function createLoginLocation(redirect) {
|
||||
};
|
||||
}
|
||||
|
||||
function createSetupLocation(redirect) {
|
||||
return {
|
||||
name: "setup",
|
||||
query: {
|
||||
redirect: getSafeLoginRedirect(redirect)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getCurrentLoginRedirect(route) {
|
||||
if (route?.name === "login" || route?.name === "login-verification") {
|
||||
return getSafeLoginRedirect(route?.query?.redirect);
|
||||
@ -85,6 +94,7 @@ function createLoginRedirectHandler(router) {
|
||||
export {
|
||||
createLoginLocation,
|
||||
createLoginRedirectHandler,
|
||||
createSetupLocation,
|
||||
getCurrentLoginRedirect,
|
||||
getSafeLoginRedirect
|
||||
};
|
||||
|
||||
@ -3,7 +3,7 @@ import { useRouter } from "vue-router"
|
||||
|
||||
import { useGlobalStatusStore } from "@/stores/useGlobalStatusStore"
|
||||
import { AUTH_STATUS } from "@/utils/auth.mjs"
|
||||
import { createLoginLocation, getSafeLoginRedirect } from "@/utils/navigation.mjs"
|
||||
import { createLoginLocation, createSetupLocation, getSafeLoginRedirect } from "@/utils/navigation.mjs"
|
||||
|
||||
export function useStartupStatus() {
|
||||
const router = useRouter()
|
||||
@ -51,6 +51,9 @@ export function useStartupStatus() {
|
||||
} else if (status === AUTH_STATUS.UNAUTHENTICATED) {
|
||||
globalStatus.clearAuthRetryPath()
|
||||
await router.replace(createLoginLocation(redirect))
|
||||
} else if (status === AUTH_STATUS.NEEDS_SETUP) {
|
||||
globalStatus.clearAuthRetryPath()
|
||||
await router.replace(createSetupLocation(redirect))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Authentication retry failed", error)
|
||||
|
||||
@ -17,17 +17,36 @@ test("认证响应会区分已登录、未登录和服务不可用", () => {
|
||||
classifyAuthenticationResponse({errorNo: 403}),
|
||||
AUTH_STATUS.UNAUTHENTICATED
|
||||
);
|
||||
assert.equal(
|
||||
classifyAuthenticationResponse({errorNo: 402}),
|
||||
AUTH_STATUS.NEEDS_SETUP
|
||||
);
|
||||
assert.equal(
|
||||
classifyAuthenticationResponse({errorNo: 500}),
|
||||
AUTH_STATUS.UNAVAILABLE
|
||||
);
|
||||
});
|
||||
|
||||
test("只有明确的 403 错误会被识别为未登录", () => {
|
||||
test("只有认证接口的 HTTP 403 或明确业务码会被识别为未登录", () => {
|
||||
assert.equal(
|
||||
classifyAuthenticationError({response: {status: 403}}),
|
||||
classifyAuthenticationError(
|
||||
{response: {status: 403}},
|
||||
{authenticationRequest: true}
|
||||
),
|
||||
AUTH_STATUS.UNAUTHENTICATED
|
||||
);
|
||||
assert.equal(
|
||||
classifyAuthenticationError({response: {status: 403}}, {authenticationRequest: false}),
|
||||
AUTH_STATUS.UNAVAILABLE
|
||||
);
|
||||
assert.equal(
|
||||
classifyAuthenticationError({response: {status: 200, data: {errorNo: 403}}}),
|
||||
AUTH_STATUS.UNAUTHENTICATED
|
||||
);
|
||||
assert.equal(
|
||||
classifyAuthenticationError({response: {status: 402}}, {authenticationRequest: true}),
|
||||
AUTH_STATUS.NEEDS_SETUP
|
||||
);
|
||||
assert.equal(
|
||||
classifyAuthenticationError({response: {status: 500}}),
|
||||
AUTH_STATUS.UNAVAILABLE
|
||||
|
||||
@ -4,7 +4,8 @@ import test from "node:test";
|
||||
import {
|
||||
createLoginRedirectHandler,
|
||||
getCurrentLoginRedirect,
|
||||
getSafeLoginRedirect
|
||||
getSafeLoginRedirect,
|
||||
createSetupLocation
|
||||
} from "../src/utils/navigation.mjs";
|
||||
|
||||
test("登录重定向只接受非登录页的站内路径", () => {
|
||||
@ -31,6 +32,17 @@ test("已在登录页时保留原有安全重定向", () => {
|
||||
}), "/");
|
||||
});
|
||||
|
||||
test("初始化跳转使用安全的站内重定向", () => {
|
||||
assert.deepEqual(createSetupLocation("/setup?redirect=/list"), {
|
||||
name: "setup",
|
||||
query: {redirect: "/"}
|
||||
});
|
||||
assert.deepEqual(createSetupLocation("/list?tag=inbox"), {
|
||||
name: "setup",
|
||||
query: {redirect: "/list?tag=inbox"}
|
||||
});
|
||||
});
|
||||
|
||||
test("并发 403 只执行一次登录跳转且不会嵌套 redirect", async () => {
|
||||
let replaceCount = 0;
|
||||
let finishNavigation;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user