@ztzx/vue-sso-interceptor
v1.3.0
Published
Vue 3 SSO authentication interceptor plugin with Casdoor support
Maintainers
Readme
@ztzx/vue-sso-interceptor
Vue 3 SSO 认证拦截器插件,支持 Casdoor OAuth2/OIDC 认证流程。
功能说明
@ztzx/vue-sso-interceptor 是一个专为 Vue 3 应用设计的 SSO(单点登录)认证拦截器插件,提供了完整的 OAuth2/OIDC 认证流程支持。该插件基于 Casdoor 认证平台,为 Vue 应用提供开箱即用的 SSO 解决方案。
核心功能
完整的 SSO 认证流程
- 支持 Casdoor OAuth2/OIDC 标准认证流程
- 自动处理授权码回调
- 支持静默授权(Silent Authentication)
- 支持强制登录模式
自动路由守卫
- 基于 Vue Router 的导航守卫
- 自动保护需要认证的路由
- 支持公开路由和回调路由配置
- 自动处理未登录用户的重定向
Token 管理
- Token 存储在 http-only Cookie 中,提高安全性
- 自动刷新 Access Token
- 支持并发请求时的 Token 刷新队列
- 自动处理 Token 过期情况
状态管理
- 基于 Pinia 的响应式状态管理
- 提供 Composition API Hook(
useAuth) - 支持本地存储持久化
跨应用 SSO
- 支持应用间跳转和认证状态共享
- 通过
fromApp参数标识来源应用 - 自动处理跨应用的登录流程
高度可配置
- 支持插件级别配置
- 支持路由守卫级别配置
- 支持环境变量配置
- 支持自定义 Axios 实例
TypeScript 支持
- 完整的 TypeScript 类型定义
- 提供类型导出,方便类型检查
安装
npm install @ztzx/vue-sso-interceptor
# 或
yarn add @ztzx/vue-sso-interceptor
# 或
pnpm add @ztzx/vue-sso-interceptor依赖要求
确保你的项目已安装以下 peer dependencies:
npm install vue@^3.5.0 vue-router@^4.0.0 pinia@^3.0.0 axios@^1.0.0使用说明
1. 基础使用
1.1 在 main.ts 中安装插件
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createRouter } from 'vue-router'
import VueSsoInterceptor from '@ztzx/vue-sso-interceptor'
import App from './App.vue'
const app = createApp(App)
const pinia = createPinia()
const router = createRouter({
// ... 你的路由配置
})
app.use(pinia)
app.use(router)
// 安装 SSO 插件
app.use(VueSsoInterceptor, {
backendBaseUrl: import.meta.env.VITE_BACKEND_BASE_URL,
appId: import.meta.env.VITE_APP_ID,
appName: import.meta.env.VITE_APP_NAME,
loginPath: '/login/casdoor',
callbackPath: '/callback/casdoor',
callbackApiPath: '/api/travel-plan', // 业务特定的回调 API 路径
publicRoutes: ['/login/casdoor'],
})
app.mount('#app')1.2 配置路由守卫
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { createAuthGuard } from '@ztzx/vue-sso-interceptor'
import HomeView from '@/views/HomeView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
},
{
path: '/login/casdoor',
name: 'login',
component: () => import('@/views/LoginView.vue'),
},
{
path: '/callback/casdoor',
name: 'casdoor-callback',
component: () => import('@/views/CasdoorCallbackView.vue'),
},
{
path: '/dashboard',
name: 'dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { requiresAuth: true }
},
],
})
// 添加认证守卫
router.beforeEach(createAuthGuard({
publicRoutes: ['/login/casdoor'],
callbackRoutes: ['/callback/casdoor'],
refreshTimeout: 3000, // 刷新 token 超时时间(毫秒)
}))
export default router1.3 创建回调页面组件
<!-- views/CasdoorCallbackView.vue -->
<template>
<div>
<p v-if="processing">正在处理回调...</p>
<p v-else-if="error">错误: {{ error }}</p>
<p v-else>登录成功!</p>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchCasdoorCallback, useAuth } from '@ztzx/vue-sso-interceptor'
const route = useRoute()
const router = useRouter()
const { setAuth } = useAuth()
const processing = ref(false)
const error = ref('')
onMounted(async () => {
const code = route.query.code as string
const state = route.query.state as string
if (code && state) {
processing.value = true
try {
const query = new URLSearchParams({ code, state }).toString()
const data = await fetchCasdoorCallback(query)
setAuth(data.token, data.user, data.accessToken)
router.replace('/')
} catch (e) {
error.value = e instanceof Error ? e.message : '未知错误'
} finally {
processing.value = false
}
}
})
</script>2. 在组件中使用
2.1 使用 Composition API Hook
<template>
<div>
<div v-if="isAuthenticated">
<p>欢迎,{{ user?.name }}</p>
<button @click="handleLogout">退出登录</button>
</div>
<div v-else>
<button @click="handleLogin">登录</button>
</div>
</div>
</template>
<script setup lang="ts">
import { useAuth } from '@ztzx/vue-sso-interceptor'
import { useRouter } from 'vue-router'
const { isAuthenticated, user, logout } = useAuth()
const router = useRouter()
const handleLogin = () => {
router.push('/login/casdoor')
}
const handleLogout = async () => {
await logout()
// 退出后重定向到登录页,触发 SSO 重新登录
window.location.href = '/login/casdoor'
}
</script>2.2 使用 Pinia Store
<script setup lang="ts">
import { useAuthStore } from '@ztzx/vue-sso-interceptor'
import { computed } from 'vue'
const auth = useAuthStore()
// 初始化(从本地存储恢复状态)
auth.initFromStorage()
const isAuthenticated = computed(() => !!auth.accessToken)
const userName = computed(() => auth.user?.name || '未登录')
const handleLogout = async () => {
await auth.clear()
window.location.href = '/login/casdoor'
}
</script>3. 跨应用 SSO
import { jumpToApp } from '@ztzx/vue-sso-interceptor'
// 跳转到其他应用,自动传递 fromApp 参数
jumpToApp('http://app-b.example.com', 'appA')详细配置
AuthConfig(插件配置)
在安装插件时传入的配置选项:
interface AuthConfig {
/**
* 后端基础 URL
* - 如果设置了则使用完整 URL(如:http://api.example.com)
* - 如果不设置则使用相对路径(通过 nginx 代理)
* - 支持环境变量:VITE_BACKEND_BASE_URL
*/
backendBaseUrl?: string
/**
* 应用标识
* - 用于 localStorage 存储前缀,避免多应用冲突
* - 默认值:'auth_interceptor'
* - 支持环境变量:VITE_APP_ID
*/
appId?: string
/**
* 应用名称
* - 备用标识,如果 appId 未设置则使用此值
* - 支持环境变量:VITE_APP_NAME
*/
appName?: string
/**
* 登录路径
* - 前端登录页面路径
* - 默认值:'/login/casdoor'
*/
loginPath?: string
/**
* 回调路径
* - Casdoor 回调后的前端路由路径
* - 默认值:'/callback/casdoor'
*/
callbackPath?: string
/**
* 回调 API 路径
* - 后端处理 Casdoor 回调的 API 路径
* - 默认值:'/api/auth/callback'
* - 重要:业务应用需要根据实际后端 API 路径配置此值
* - 支持环境变量:VITE_CALLBACK_API_PATH
*
* 示例:
* - '/api/travel-plan' - 旅游业务应用
* - '/api/order/callback' - 订单业务应用
*/
callbackApiPath?: string
/**
* 公开路由列表
* - 这些路由不需要认证即可访问
* - 默认值:['/login/casdoor']
*/
publicRoutes?: string[]
/**
* 自定义 Axios 实例
* - 如果不提供则使用默认的 axios
* - 可以用于配置请求拦截器、响应拦截器等
*/
axiosInstance?: AxiosInstance
}GuardOptions(路由守卫配置)
在创建路由守卫时传入的配置选项:
interface GuardOptions {
/**
* 公开路由列表
* - 这些路由不需要认证即可访问
* - 会与插件配置中的 publicRoutes 合并
*/
publicRoutes?: string[]
/**
* 回调路由路径列表
* - 这些路由在带 code 和 state 参数时不需要认证
* - 默认值:[config.callbackPath]
* - 用于处理 Casdoor 回调
*/
callbackRoutes?: string[]
/**
* 登录路径
* - 默认从插件配置中获取
* - 可以在此处覆盖
*/
loginPath?: string
/**
* 刷新 token 超时时间(毫秒)
* - 默认不设置超时
* - 如果设置了,刷新 token 超过此时间会失败
*/
refreshTimeout?: number
/**
* 是否允许首页在未登录时访问
* - 默认值:false
* - 如果设置为 true,路径为 '/' 的路由在未登录时也可以访问
*/
allowHomeWithoutAuth?: boolean
}环境变量配置
支持通过环境变量配置(Vite 项目):
# .env
VITE_BACKEND_BASE_URL=http://localhost:8080
VITE_APP_ID=my-app
VITE_APP_NAME=my-app
VITE_CALLBACK_API_PATH=/api/travel-plan配置优先级:用户配置 > 环境变量 > 默认配置
配置示例
示例 1:基础配置
app.use(VueSsoInterceptor, {
backendBaseUrl: 'http://localhost:8080',
appId: 'my-app',
loginPath: '/login/casdoor',
callbackPath: '/callback/casdoor',
callbackApiPath: '/api/auth/callback',
publicRoutes: ['/login/casdoor', '/about'],
})示例 2:使用环境变量
app.use(VueSsoInterceptor, {
backendBaseUrl: import.meta.env.VITE_BACKEND_BASE_URL,
appId: import.meta.env.VITE_APP_ID,
appName: import.meta.env.VITE_APP_NAME,
callbackApiPath: import.meta.env.VITE_CALLBACK_API_PATH || '/api/auth/callback',
loginPath: '/login/casdoor',
callbackPath: '/callback/casdoor',
})示例 3:自定义 Axios 实例
import axios from 'axios'
const axiosInstance = axios.create({
baseURL: 'https://api.example.com',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
})
// 添加请求拦截器
axiosInstance.interceptors.request.use(
(config) => {
// 添加自定义请求头等
return config
},
(error) => Promise.reject(error)
)
app.use(VueSsoInterceptor, {
axiosInstance,
backendBaseUrl: 'https://api.example.com',
appId: 'my-app',
})示例 4:路由守卫配置
router.beforeEach(createAuthGuard({
publicRoutes: ['/login/casdoor', '/about', '/help'],
callbackRoutes: ['/callback/casdoor', '/callback/oauth'],
allowHomeWithoutAuth: true,
refreshTimeout: 5000,
}))API 文档
Store API
useAuthStore
Pinia store,管理认证状态。
import { useAuthStore } from '@ztzx/vue-sso-interceptor'
const auth = useAuthStore()
// 状态属性
auth.token // Token(存储在 http-only Cookie 中,这里仅用于状态管理)
auth.accessToken // Access Token
auth.user // 用户信息(UserInfo 类型)
auth.loading // 加载状态
auth.error // 错误信息
// 方法
auth.initFromStorage() // 从本地存储初始化状态
auth.setAuth(token, user, accessToken) // 设置认证信息
auth.setUser(user) // 设置用户信息
auth.clear() // 清除认证信息
auth.setLoading(flag) // 设置加载状态
auth.setError(message) // 设置错误信息API 服务
fetchCasdoorCallback
处理 Casdoor 回调,将授权码换取 Token。
import { fetchCasdoorCallback } from '@ztzx/vue-sso-interceptor'
/**
* @param queryString - URL 查询字符串(如:'code=xxx&state=xxx')
* @returns Promise<AuthResponse> - { token, accessToken, user }
*/
const data = await fetchCasdoorCallback('code=xxx&state=xxx')fetchUserInfo
获取用户信息。
import { fetchUserInfo } from '@ztzx/vue-sso-interceptor'
/**
* @param token - Token(可选,从 Cookie 中自动获取)
* @param accessToken - Access Token
* @returns Promise<UserInfo>
*/
const userInfo = await fetchUserInfo(null, accessToken)refreshAccessToken
刷新 Access Token。
import { refreshAccessToken } from '@ztzx/vue-sso-interceptor'
/**
* @returns Promise<RefreshTokenResponse> - { accessToken, expiresIn? }
*/
const result = await refreshAccessToken()logout
退出登录。
import { logout } from '@ztzx/vue-sso-interceptor'
/**
* @param token - Token(可选,从 Cookie 中自动获取)
* @param accessToken - Access Token
* @returns Promise<void>
*/
await logout(null, accessToken)fetchPing
健康检查。
import { fetchPing } from '@ztzx/vue-sso-interceptor'
const result = await fetchPing()setupAxiosInterceptor
设置 Axios 响应拦截器,自动处理 401 错误。
import { setupAxiosInterceptor } from '@ztzx/vue-sso-interceptor'
import axios from 'axios'
const axiosInstance = axios.create()
setupAxiosInterceptor(axiosInstance, '/login/casdoor')Composables
useAuth
Composition API Hook,提供响应式的认证状态和便捷方法。
import { useAuth } from '@ztzx/vue-sso-interceptor'
const {
// 响应式状态
isAuthenticated, // boolean - 是否已登录
user, // Ref<UserInfo | null> - 用户信息
accessToken, // Ref<string | null> - Access Token
loading, // Ref<boolean> - 加载状态
error, // Ref<string | null> - 错误信息
// 方法
initAuth, // () => void - 初始化认证状态
setAuth, // (token, user, accessToken) => void - 设置认证信息
setUser, // (user) => void - 设置用户信息
getUserInfo, // () => Promise<UserInfo> - 获取用户信息(从服务器)
refreshToken, // () => Promise<void> - 刷新 Token
logout, // () => Promise<void> - 退出登录
clear, // () => void - 清除认证信息
} = useAuth()工具函数
jumpToApp
跨应用跳转(跨应用 SSO)。
import { jumpToApp } from '@ztzx/vue-sso-interceptor'
/**
* @param targetUrl - 目标应用 URL
* @param fromAppId - 来源应用标识
*/
jumpToApp('http://app-b.example.com', 'appA')getCurrentAppId
获取当前应用标识。
import { getCurrentAppId } from '@ztzx/vue-sso-interceptor'
const appId = getCurrentAppId()类型定义
所有类型都已导出,可以直接使用:
import type {
UserInfo, // 用户信息接口
AuthState, // 认证状态接口
AuthConfig, // 插件配置接口
AuthResponse, // 认证响应接口
RefreshTokenResponse, // 刷新 Token 响应接口
GuardOptions, // 路由守卫配置接口
} from '@ztzx/vue-sso-interceptor'实现方式
架构设计
插件采用模块化设计,主要包含以下模块:
src/
├── index.ts # 插件入口,导出所有公共 API
├── types.ts # TypeScript 类型定义
├── config.ts # 配置管理模块
├── store/
│ └── auth.ts # Pinia Store(状态管理)
├── services/
│ └── authApi.ts # API 服务(HTTP 请求)
├── router/
│ └── guard.ts # 路由守卫
├── composables/
│ └── useAuth.ts # Composition API Hook
└── utils/
└── appNavigation.ts # 工具函数(跨应用跳转等)核心实现
1. 配置管理(config.ts)
配置管理采用三层优先级机制:
- 默认配置:提供合理的默认值
- 环境变量配置:从 Vite 环境变量读取
- 用户配置:插件安装时传入的配置
配置合并逻辑:
// 合并顺序:用户配置 > 环境变量 > 默认配置
globalConfig = {
...DEFAULT_CONFIG,
...envConfig,
...config,
}关键设计:getConfig() 函数在配置未初始化时,返回默认配置但不修改 globalConfig,避免在插件初始化前(如路由守卫创建时)重置用户配置。
2. 状态管理(store/auth.ts)
基于 Pinia 实现响应式状态管理:
- 状态持久化:使用 localStorage 存储
accessToken和user信息 - 存储键隔离:通过
appId前缀避免多应用冲突 - 状态同步:提供
initFromStorage()方法从本地存储恢复状态
3. API 服务(services/authApi.ts)
封装所有与后端交互的 HTTP 请求:
- Token 管理:Token 存储在 http-only Cookie 中,提高安全性
- 自动刷新:实现并发请求时的 Token 刷新队列机制
- 错误处理:统一处理 401 错误,自动跳转登录页
Token 刷新队列实现:
let isRefreshing = false
let failedQueue: Array<{ resolve, reject }> = []
// 当检测到 401 错误时
if (error.response?.status === 401 && !isRefreshing) {
isRefreshing = true
// 刷新 Token
// 成功后处理队列中的所有请求
// 失败后拒绝队列中的所有请求
}4. 路由守卫(router/guard.ts)
基于 Vue Router 的 beforeEach 导航守卫实现:
路由检查流程:
- 公开路由检查:如果是公开路由,直接放行
- 回调路由检查:如果是回调路由且带有
code和state参数,放行 - 登录页处理:如果是登录页,检测
fromApp和forceLogin参数 - 跨应用跳转处理:检测
fromApp参数,处理跨应用 SSO - Token 检查:检查
accessToken,如果没有则尝试刷新 - 自动刷新:如果刷新成功,保存新的
accessToken并放行 - 跳转登录:如果刷新失败,清除状态并跳转到登录页
静默授权支持:
- 默认使用静默授权(
prompt=none) - 如果用户在 Casdoor 已登录,自动完成登录
- 如果静默授权失败(
error=login_required),重定向到正常登录流程
5. Composition API Hook(composables/useAuth.ts)
提供响应式的认证状态和便捷方法:
- 基于 Pinia Store 封装
- 提供响应式的
isAuthenticated、user、accessToken等状态 - 封装常用的认证操作方法
6. 跨应用 SSO(utils/appNavigation.ts)
实现应用间跳转和认证状态共享:
- 通过 URL 参数
fromApp标识来源应用 - 目标应用检测到
fromApp参数时,使用跨应用 SSO 流程 - 支持强制登录模式(
forceLogin=true)
工作流程
登录流程
- 用户访问需要认证的页面
- 路由守卫检测到未登录,重定向到
/login/casdoor - 前端跳转到后端登录接口(携带
fromApp、forceLogin等参数) - 后端重定向到 Casdoor 授权页面
- 用户在 Casdoor 完成登录
- Casdoor 回调到后端(携带
code和state) - 后端处理回调,设置 http-only Cookie,重定向到前端回调页面
- 前端回调页面调用
fetchCasdoorCallback()获取用户信息 - 保存
accessToken和user到 Store 和 localStorage - 重定向到原始目标页面
Token 刷新流程
- API 请求返回 401 错误
- Axios 响应拦截器捕获错误
- 检查是否正在刷新(
isRefreshing) - 如果未在刷新,发起刷新请求,并将当前请求加入队列
- 如果正在刷新,将当前请求加入队列
- 刷新成功后,处理队列中的所有请求
- 刷新失败后,拒绝队列中的所有请求,跳转登录页
跨应用 SSO 流程
- 应用 A 调用
jumpToApp('http://app-b.com', 'appA') - 跳转到应用 B,URL 携带
fromApp=appA参数 - 应用 B 的路由守卫检测到
fromApp参数 - 如果用户未登录,跳转到登录页(保留
fromApp参数) - 后端检测到
fromApp参数,使用跨应用 SSO 流程 - 如果用户在 Casdoor 已登录,自动完成登录
- 登录成功后,应用 B 可以获取用户信息
安全考虑
- Token 存储:Token 存储在 http-only Cookie 中,防止 XSS 攻击
- Access Token:前端只存储
accessToken,用于 API 请求 - 自动刷新:Token 过期时自动刷新,无需用户重新登录
- 并发控制:Token 刷新时使用队列机制,避免并发刷新
- 错误处理:统一的错误处理机制,确保安全性
配置初始化时机
关键设计:插件支持在路由守卫创建时(模块加载时)调用 getConfig(),此时配置可能尚未初始化。为了解决这个问题:
getConfig()在配置未初始化时,返回默认配置但不修改globalConfig- 这样即使路由守卫在插件初始化之前被调用,也不会影响后续的配置初始化
- 插件初始化时调用
initConfig(options)会正确设置用户配置
这种设计确保了配置的灵活性和可靠性。
注意事项
Token 存储:Token 存储在 http-only Cookie 中,前端无法直接读取,只能通过
accessToken判断认证状态。路由守卫顺序:路由守卫必须在路由配置后添加,确保 Pinia 和 Vue Router 已安装。
回调 API 路径:
callbackApiPath必须根据实际后端 API 路径配置,不同业务应用可能有不同的路径。跨应用 SSO:使用
jumpToApp函数实现跨应用跳转,目标应用需要支持fromApp参数。静默授权:默认支持静默授权(
prompt=none),如果用户在 Casdoor 已登录,将自动完成登录。自动刷新:当
accessToken过期时,会自动尝试刷新,如果刷新失败会跳转到登录页。环境变量:如果使用环境变量配置,确保变量名以
VITE_开头(Vite 项目要求)。TypeScript:插件提供完整的 TypeScript 类型定义,建议在 TypeScript 项目中使用以获得更好的类型检查。
许可证
MIT
