修复初始化认证和权限跳转问题
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-04 10:03:50 +08:00
parent 8fc121220e
commit 40c6b09168
9 changed files with 99 additions and 27 deletions

View File

@ -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">

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

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

View File

@ -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
};

View File

@ -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)

View File

@ -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

View File

@ -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;