修复认证异常和登录重定向问题
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-03 20:46:02 +08:00
parent a5f345293a
commit 8fc121220e
16 changed files with 598 additions and 55 deletions

View File

@ -6,6 +6,7 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "node --test tests/*.test.mjs",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
},
"dependencies": {

View File

@ -3,6 +3,7 @@ import {RouterView, useRoute} from 'vue-router'
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 {useGlobalStatusStore} from "@/stores/useGlobalStatusStore";
@ -21,7 +22,8 @@ watch(
</script>
<template>
<div id="main">
<StartupStatus v-if="globalStatus.startupBlocked" />
<div v-else id="main">
<HomeHeader v-if="!standalonePages.includes(pageName)"/>
<div id="content">
<div id="aside" v-if="!standalonePages.includes(pageName)">

View File

@ -2,6 +2,7 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import {router} from './router'
import {useGlobalStatusStore} from './stores/useGlobalStatusStore'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
@ -30,11 +31,15 @@ if (isReservedResetPath(window.location.pathname)) {
const app = createApp(App)
const pinia = createPinia()
const globalStatus = useGlobalStatusStore(pinia)
app.use(pinia)
router.onError((error) => {
console.error('Router navigation failed', error)
globalStatus.setStartupError('页面加载失败,请刷新后重试')
})
app.use(router)
app.use(ElementPlus)
router.isReady().then(() => {
app.mount('#app')
})
app.mount('#app')

View File

@ -1,5 +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";
const router = createRouter({
history: createWebHashHistory(),
@ -65,13 +67,32 @@ const router = createRouter({
router.beforeEach(async (to) => {
const globalStatus = useGlobalStatusStore();
const needsAuthentication = to.meta.requiresAuth || to.name === "force-password-change";
const authenticated = globalStatus.isLogin || (needsAuthentication && await globalStatus.refresh());
let authStatus = globalStatus.isLogin
? AUTH_STATUS.AUTHENTICATED
: globalStatus.authStatus;
if (needsAuthentication && authStatus !== AUTH_STATUS.AUTHENTICATED) {
authStatus = await globalStatus.refresh();
}
if (needsAuthentication && authStatus === AUTH_STATUS.UNAVAILABLE) {
globalStatus.setAuthRetryPath(to.fullPath);
return false;
}
if (!needsAuthentication && authStatus === AUTH_STATUS.UNAVAILABLE) {
globalStatus.resetAuthAvailability();
authStatus = AUTH_STATUS.UNKNOWN;
}
const authenticated = authStatus === AUTH_STATUS.AUTHENTICATED;
if (!authenticated && needsAuthentication) {
return {
name: "login",
query: {redirect: to.fullPath}
};
return createLoginLocation(to.fullPath);
}
if (authenticated) {
globalStatus.clearAuthRetryPath();
}
if (to.name === "force-password-change") {

View File

@ -1,7 +1,12 @@
import { defineStore } from "pinia";
import { http } from "@/utils/axios";
let authRefreshPromise = null;
import {
AUTH_STATUS,
classifyAuthenticationError,
classifyAuthenticationResponse,
runSharedAuthRefresh
} from "@/utils/auth.mjs";
import {getSafeLoginRedirect} from "@/utils/navigation.mjs";
const useGlobalStatusStore = defineStore("useGlobalStatusStore", {
state() {
@ -11,6 +16,10 @@ const useGlobalStatusStore = defineStore("useGlobalStatusStore", {
settingsDrawerVisible: false,
authChecked: false,
authLoading: false,
authStatus: AUTH_STATUS.UNKNOWN,
authError: "",
authRetryPath: "/",
startupError: "",
pendingLogin: null,
recoveryPromptDismissed: false
};
@ -26,42 +35,49 @@ const useGlobalStatusStore = defineStore("useGlobalStatusStore", {
return Array.isArray(state.userInfos.capabilities)
? state.userInfos.capabilities
: [];
},
startupBlocked(state) {
return Boolean(state.startupError)
|| (state.authLoading && Object.keys(state.userInfos).length === 0)
|| state.authStatus === AUTH_STATUS.UNAVAILABLE;
}
},
actions: {
refresh() {
if (authRefreshPromise) {
return authRefreshPromise;
}
this.authLoading = true;
authRefreshPromise = (async () => {
return runSharedAuthRefresh(async () => {
this.authLoading = true;
this.authStatus = AUTH_STATUS.LOADING;
this.authError = "";
try {
const response = await http.post("/api/v1/user/info", {});
if (response.errorNo === 0 && response.data) {
const result = classifyAuthenticationResponse(response);
if (result === AUTH_STATUS.AUTHENTICATED) {
this.setUser(response.data, false);
this.authChecked = true;
return true;
} else if (result === AUTH_STATUS.UNAUTHENTICATED) {
this.clear();
} else {
this.markAuthUnavailable();
}
this.clear();
return false;
return result;
} catch (error) {
this.clear();
return false;
const result = classifyAuthenticationError(error);
if (result === AUTH_STATUS.UNAUTHENTICATED) {
this.clear();
} else {
this.markAuthUnavailable();
}
return result;
} finally {
this.authLoading = false;
authRefreshPromise = null;
}
})();
return authRefreshPromise;
});
},
init(callback) {
return this.refresh().then((authenticated) => {
return this.refresh().then((status) => {
if (typeof callback === "function") {
callback(authenticated);
callback(status);
}
return authenticated;
return status;
});
},
clear() {
@ -69,6 +85,8 @@ const useGlobalStatusStore = defineStore("useGlobalStatusStore", {
this.pendingLogin = null;
this.recoveryPromptDismissed = false;
this.authChecked = true;
this.authStatus = AUTH_STATUS.UNAUTHENTICATED;
this.authError = "";
},
setUser(payload, resetPrompt = false) {
const previousAccount = this.userInfos?.account || "";
@ -77,6 +95,30 @@ const useGlobalStatusStore = defineStore("useGlobalStatusStore", {
this.recoveryPromptDismissed = false;
}
this.authChecked = true;
this.authStatus = AUTH_STATUS.AUTHENTICATED;
this.authError = "";
},
markAuthUnavailable() {
this.authChecked = true;
this.authStatus = AUTH_STATUS.UNAVAILABLE;
this.authError = "认证服务暂时不可用,请检查网络后重试";
},
resetAuthAvailability() {
this.authChecked = false;
this.authStatus = AUTH_STATUS.UNKNOWN;
this.authError = "";
},
setAuthRetryPath(path) {
this.authRetryPath = typeof path === "string" && path ? path : "/";
},
clearAuthRetryPath() {
this.authRetryPath = "/";
},
setStartupError(message) {
this.startupError = message || "页面加载失败,请刷新后重试";
},
clearStartupError() {
this.startupError = "";
},
dismissRecoveryPrompt() {
this.recoveryPromptDismissed = true;
@ -87,7 +129,7 @@ const useGlobalStatusStore = defineStore("useGlobalStatusStore", {
account: payload?.account || "",
email: payload?.email || "",
expiresAt: payload?.expires_at || "",
redirect: payload?.redirect || "/"
redirect: getSafeLoginRedirect(payload?.redirect)
};
},
clearPendingLogin() {

47
fe/src/utils/auth.mjs Normal file
View File

@ -0,0 +1,47 @@
const AUTH_STATUS = Object.freeze({
UNKNOWN: "unknown",
LOADING: "loading",
AUTHENTICATED: "authenticated",
UNAUTHENTICATED: "unauthenticated",
UNAVAILABLE: "unavailable"
});
let authRefreshPromise = null;
function classifyAuthenticationResponse(response) {
if (response?.errorNo === 0 && response.data && typeof response.data === "object") {
return AUTH_STATUS.AUTHENTICATED;
}
if (response?.errorNo === 403) {
return AUTH_STATUS.UNAUTHENTICATED;
}
return AUTH_STATUS.UNAVAILABLE;
}
function classifyAuthenticationError(error) {
if (error?.response?.status === 403 || error?.response?.data?.errorNo === 403) {
return AUTH_STATUS.UNAUTHENTICATED;
}
return AUTH_STATUS.UNAVAILABLE;
}
function runSharedAuthRefresh(task) {
if (authRefreshPromise) {
return authRefreshPromise;
}
authRefreshPromise = Promise.resolve()
.then(task)
.finally(() => {
authRefreshPromise = null;
});
return authRefreshPromise;
}
export {
AUTH_STATUS,
classifyAuthenticationError,
classifyAuthenticationResponse,
runSharedAuthRefresh
};

View File

@ -1,6 +1,7 @@
import axios from 'axios'
import lang from '../i18n/i18n';
import {router} from "@/router";
import {createLoginRedirectHandler} from "@/utils/navigation.mjs";
//创建axios的一个实例
const http = axios.create({
@ -17,6 +18,15 @@ const http = axios.create({
}
});
let redirectToLogin = null;
function handleLoginRedirect() {
if (!redirectToLogin) {
redirectToLogin = createLoginRedirectHandler(router);
}
return redirectToLogin();
}
//请求拦截器
http.interceptors.request.use((config) => {
//若请求方式为post,则将data参数转为JSON字符串
@ -56,16 +66,17 @@ function isSetupRequest(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'
}
http.interceptors.response.use(async (response) => {
//响应成功
if (response.data?.errorNo === 403 && !isSetupRequest(response.config)) {
await router.replace({
path: '/login',
query: {
redirect: router.currentRoute.value.fullPath
}
});
if (response.data?.errorNo === 403
&& !isSetupRequest(response.config)
&& !isAuthenticationRefreshRequest(response.config)) {
await handleLoginRedirect();
}
//响应成功
if (response.data?.errorNo === 402 && !isSetupRequest(response.config)) {
@ -91,13 +102,10 @@ http.interceptors.response.use(async (response) => {
case 403:
if (isSetupRequest(error.config)) {
message = lang.setup_failed
} else if (isAuthenticationRefreshRequest(error.config)) {
message = '登录状态已失效'
} else {
await router.replace({
path: '/login',
query: {
redirect: router.currentRoute.value.fullPath
}
});
await handleLoginRedirect();
}
break;
case 404:

View File

@ -0,0 +1,90 @@
const BLOCKED_REDIRECT_PATHS = ["/login", "/login-verification"];
function decodeRedirect(value) {
let decoded = value;
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
const next = decodeURIComponent(decoded);
if (next === decoded) {
break;
}
decoded = next;
} catch {
break;
}
}
return decoded;
}
function getSafeLoginRedirect(value, fallback = "/") {
if (typeof value !== "string") {
return fallback;
}
const redirect = value.trim();
if (!redirect.startsWith("/") || redirect.startsWith("//")) {
return fallback;
}
const decodedRedirect = decodeRedirect(redirect);
if (!decodedRedirect.startsWith("/") || decodedRedirect.startsWith("//") || decodedRedirect.includes("\\")) {
return fallback;
}
const decodedPath = decodedRedirect
.split(/[?#]/, 1)[0]
.replace(/\/+$/, "")
.toLowerCase() || "/";
if (BLOCKED_REDIRECT_PATHS.some((path) => decodedPath === path || decodedPath.startsWith(`${path}/`))) {
return fallback;
}
return redirect;
}
function createLoginLocation(redirect) {
return {
name: "login",
query: {
redirect: getSafeLoginRedirect(redirect)
}
};
}
function getCurrentLoginRedirect(route) {
if (route?.name === "login" || route?.name === "login-verification") {
return getSafeLoginRedirect(route?.query?.redirect);
}
return getSafeLoginRedirect(route?.fullPath);
}
function createLoginRedirectHandler(router) {
let redirectPromise = null;
return function redirectToLogin() {
const currentRoute = router.currentRoute.value;
if (currentRoute?.name === "login" || currentRoute?.name === "login-verification") {
return Promise.resolve(false);
}
if (redirectPromise) {
return redirectPromise;
}
const location = createLoginLocation(getCurrentLoginRedirect(currentRoute));
redirectPromise = Promise.resolve(router.replace(location))
.then(() => true)
.finally(() => {
redirectPromise = null;
});
return redirectPromise;
};
}
export {
createLoginLocation,
createLoginRedirectHandler,
getCurrentLoginRedirect,
getSafeLoginRedirect
};

View File

@ -53,10 +53,17 @@ export function usePage() {
})
}
const updateGroupList = function () {
http.post("/api/v1/group/list").then(res => {
const updateGroupList = async function () {
try {
const res = await http.get("/api/v1/group/list")
if (res.errorNo !== 0) {
throw new Error(res.errorMsg || "分组列表加载失败")
}
groupList.value = res.data || []
})
} catch (error) {
groupList.value = []
ElMessage.error(error?.message || "分组列表加载失败,请稍后重试")
}
}
updateList()

View File

@ -9,6 +9,7 @@ import lang from "../../i18n/i18n"
import { http } from "@/utils/axios"
import { useGlobalStatusStore } from "@/stores/useGlobalStatusStore"
import { getSafeLoginRedirect } from "@/utils/navigation.mjs"
export function usePage() {
const router = useRouter()
@ -31,9 +32,7 @@ export function usePage() {
ElMessage.error(res.errorMsg)
return
}
const redirect = typeof router.currentRoute.value.query.redirect === "string"
? router.currentRoute.value.query.redirect
: "/"
const redirect = getSafeLoginRedirect(router.currentRoute.value.query.redirect)
if (res.data?.pending) {
globalStatus.setPendingLogin({ ...res.data, redirect })
await router.replace({ name: "login-verification" })

View File

@ -6,6 +6,7 @@ import { useRouter } from "vue-router"
import { useGlobalStatusStore } from "@/stores/useGlobalStatusStore"
import { http } from "@/utils/axios"
import { getSafeLoginRedirect } from "@/utils/navigation.mjs"
import {
normalizeRequestOptions,
serializeCredential,
@ -77,7 +78,7 @@ export function usePage() {
if (response.errorNo !== 0) {
throw new Error(response.errorMsg || "验证码验证失败")
}
const redirect = pending.value.redirect || "/"
const redirect = getSafeLoginRedirect(pending.value.redirect)
globalStatus.clearPendingLogin()
globalStatus.setUser(response.data, true)
await router.replace(redirect)
@ -145,7 +146,7 @@ export function usePage() {
if (finish.errorNo !== 0) {
throw new Error(finish.errorMsg || "安全密钥验证失败")
}
const redirect = pending.value?.redirect || "/"
const redirect = getSafeLoginRedirect(pending.value?.redirect)
globalStatus.clearPendingLogin()
globalStatus.setUser(finish.data, true)
await router.replace(redirect)

View File

@ -0,0 +1,74 @@
.startup-status {
width: 100%;
min-height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
box-sizing: border-box;
background: var(--pm-bg-primary);
}
.startup-status__card {
width: min(440px, 100%);
padding: 36px;
box-sizing: border-box;
text-align: center;
background: var(--pm-surface-solid);
border: 1px solid var(--pm-border-color);
border-radius: var(--pm-radius-xl);
box-shadow: var(--pm-shadow-md);
}
.startup-status__label {
display: inline-block;
margin-bottom: 12px;
color: var(--pm-primary-color);
font-size: 14px;
font-weight: 700;
letter-spacing: 0.08em;
}
.startup-status__card h1 {
margin: 0;
color: var(--pm-text-primary);
font-size: 24px;
}
.startup-status__card p {
margin: 16px 0 24px;
color: var(--pm-text-secondary);
line-height: 1.7;
}
.startup-status__loading {
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--pm-text-secondary);
}
.startup-status__spinner {
width: 18px;
height: 18px;
border: 2px solid var(--pm-border-color);
border-top-color: var(--pm-primary-color);
border-radius: 50%;
animation: startup-status-spin 0.8s linear infinite;
}
@keyframes startup-status-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 600px) {
.startup-status {
padding: 16px;
}
.startup-status__card {
padding: 28px 22px;
}
}

View File

@ -0,0 +1,70 @@
import { computed, ref } from "vue"
import { useRouter } from "vue-router"
import { useGlobalStatusStore } from "@/stores/useGlobalStatusStore"
import { AUTH_STATUS } from "@/utils/auth.mjs"
import { createLoginLocation, getSafeLoginRedirect } from "@/utils/navigation.mjs"
export function useStartupStatus() {
const router = useRouter()
const globalStatus = useGlobalStatusStore()
const retrying = ref(false)
const isLoading = computed(() => globalStatus.authLoading || retrying.value)
const hasStartupError = computed(() => Boolean(globalStatus.startupError))
const title = computed(() => {
if (hasStartupError.value) {
return "页面加载失败"
}
if (isLoading.value) {
return "正在恢复登录状态"
}
return "认证服务暂时不可用"
})
const description = computed(() => {
if (hasStartupError.value) {
return globalStatus.startupError
}
if (isLoading.value) {
return "正在确认当前登录会话,请稍候。"
}
return globalStatus.authError || "暂时无法确认登录状态,请检查网络后重试。"
})
const buttonLabel = computed(() => hasStartupError.value ? "刷新页面" : "重新检查")
const retry = async () => {
if (isLoading.value) {
return
}
if (hasStartupError.value) {
window.location.reload()
return
}
retrying.value = true
const redirect = getSafeLoginRedirect(globalStatus.authRetryPath)
try {
const status = await globalStatus.refresh()
if (status === AUTH_STATUS.AUTHENTICATED) {
globalStatus.clearAuthRetryPath()
await router.replace(redirect)
} else if (status === AUTH_STATUS.UNAUTHENTICATED) {
globalStatus.clearAuthRetryPath()
await router.replace(createLoginLocation(redirect))
}
} catch (error) {
console.error("Authentication retry failed", error)
globalStatus.setStartupError("页面加载失败,请刷新后重试")
} finally {
retrying.value = false
}
}
return {
buttonLabel,
description,
isLoading,
retry,
title
}
}

View File

@ -0,0 +1,35 @@
<script setup>
import { useStartupStatus } from "./StartupStatus.js"
const {
buttonLabel,
description,
isLoading,
retry,
title
} = useStartupStatus()
</script>
<template>
<main class="startup-status" aria-live="polite">
<section class="startup-status__card">
<span class="startup-status__label">Smail</span>
<h1>{{ title }}</h1>
<p>{{ description }}</p>
<el-button
v-if="!isLoading"
type="primary"
size="large"
@click="retry"
>
{{ buttonLabel }}
</el-button>
<div v-else class="startup-status__loading" role="status">
<span class="startup-status__spinner" aria-hidden="true"></span>
<span>请稍候</span>
</div>
</section>
</main>
</template>
<style scoped src="./StartupStatus.css"></style>

68
fe/tests/auth.test.mjs Normal file
View File

@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
AUTH_STATUS,
classifyAuthenticationError,
classifyAuthenticationResponse,
runSharedAuthRefresh
} from "../src/utils/auth.mjs";
test("认证响应会区分已登录、未登录和服务不可用", () => {
assert.equal(
classifyAuthenticationResponse({errorNo: 0, data: {account: "user@example.com"}}),
AUTH_STATUS.AUTHENTICATED
);
assert.equal(
classifyAuthenticationResponse({errorNo: 403}),
AUTH_STATUS.UNAUTHENTICATED
);
assert.equal(
classifyAuthenticationResponse({errorNo: 500}),
AUTH_STATUS.UNAVAILABLE
);
});
test("只有明确的 403 错误会被识别为未登录", () => {
assert.equal(
classifyAuthenticationError({response: {status: 403}}),
AUTH_STATUS.UNAUTHENTICATED
);
assert.equal(
classifyAuthenticationError({response: {status: 500}}),
AUTH_STATUS.UNAVAILABLE
);
assert.equal(
classifyAuthenticationError(new Error("Network Error")),
AUTH_STATUS.UNAVAILABLE
);
});
test("并发认证刷新只执行一次请求并返回同一个结果", async () => {
let requestCount = 0;
let resolveRequest;
const request = () => {
requestCount += 1;
return new Promise((resolve) => {
resolveRequest = resolve;
});
};
const first = runSharedAuthRefresh(request);
const second = runSharedAuthRefresh(request);
assert.strictEqual(first, second);
await Promise.resolve();
assert.equal(requestCount, 1);
resolveRequest(AUTH_STATUS.AUTHENTICATED);
assert.equal(await first, AUTH_STATUS.AUTHENTICATED);
assert.equal(await second, AUTH_STATUS.AUTHENTICATED);
const third = runSharedAuthRefresh(async () => {
requestCount += 1;
return AUTH_STATUS.UNAUTHENTICATED;
});
assert.equal(await third, AUTH_STATUS.UNAUTHENTICATED);
assert.equal(requestCount, 2);
});

View File

@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
createLoginRedirectHandler,
getCurrentLoginRedirect,
getSafeLoginRedirect
} from "../src/utils/navigation.mjs";
test("登录重定向只接受非登录页的站内路径", () => {
assert.equal(getSafeLoginRedirect("/list?tag=inbox"), "/list?tag=inbox");
assert.equal(getSafeLoginRedirect("https://example.com"), "/");
assert.equal(getSafeLoginRedirect("//example.com"), "/");
assert.equal(getSafeLoginRedirect("/%2fexample.com"), "/");
assert.equal(getSafeLoginRedirect("/login?redirect=/list"), "/");
assert.equal(getSafeLoginRedirect("/%6Cogin-verification"), "/");
assert.equal(getSafeLoginRedirect("/login/extra"), "/");
});
test("已在登录页时保留原有安全重定向", () => {
assert.equal(getCurrentLoginRedirect({
name: "login",
fullPath: "/login?redirect=/list",
query: {redirect: "/list"}
}), "/list");
assert.equal(getCurrentLoginRedirect({
name: "login",
fullPath: "/login?redirect=/login",
query: {redirect: "/login"}
}), "/");
});
test("并发 403 只执行一次登录跳转且不会嵌套 redirect", async () => {
let replaceCount = 0;
let finishNavigation;
const router = {
currentRoute: {
value: {
name: "list",
fullPath: "/list?tag=inbox",
query: {}
}
},
replace(location) {
replaceCount += 1;
return new Promise((resolve) => {
finishNavigation = () => {
router.currentRoute.value = {
name: "login",
fullPath: "/login?redirect=/list?tag=inbox",
query: location.query
};
resolve();
};
});
}
};
const redirectToLogin = createLoginRedirectHandler(router);
const first = redirectToLogin();
const second = redirectToLogin();
assert.strictEqual(first, second);
assert.equal(replaceCount, 1);
finishNavigation();
assert.equal(await first, true);
assert.equal(router.currentRoute.value.query.redirect, "/list?tag=inbox");
assert.equal(await redirectToLogin(), false);
assert.equal(replaceCount, 1);
assert.equal(router.currentRoute.value.query.redirect, "/list?tag=inbox");
});