登录鉴权与路由守卫
1. 前端鉴权的全景图
后端视角里"鉴权"就是 SecurityFilterChain:请求进来,过滤器一层层过,不通过就 401/403。前端也一样,只不过过滤器分散在几个位置:
用户点"登录" → [登录页]
↓
POST /api/login → 拿到 token
↓
[Pinia user store] ← 存 token + 用户信息
↓
[Axios 拦截器] ← 每个请求自动加 Authorization 头
↓
[路由守卫] ← 切页面时检查有没有 token
↓
遇到 401 → 清 token → 跳登录页
四个角色:
- Pinia store —— 保管 token 和用户信息(类比
SecurityContextHolder) - Axios 拦截器 —— 每个请求自动加 token(类比
OncePerRequestFilter) - 路由守卫 —— 页面跳转前判断是否已登录(类比
AccessDecisionManager) - localStorage —— token 持久化(类比服务端的 Session 存储)
Java 对照:
| 前端 | Spring Security |
|---|---|
| Pinia user store | SecurityContextHolder |
| Axios 请求拦截器 | JwtAuthenticationFilter(OncePerRequestFilter) |
| Axios 响应拦截器 | 异常处理 @ControllerAdvice |
路由守卫 beforeEach |
AuthorizationFilter / @PreAuthorize |
| localStorage 存 token | Redis 存 Session |
| 登录接口 | AuthenticationManager.authenticate() |
2. token 存哪:localStorage vs Cookie
localStorage
- ✅ 简单,JS 直接读写
- ✅ 不会被自动发送到服务器(需手动加到 Authorization 头)
- ❌ 无法防御 XSS:只要页面被注入 JS,就能偷到 token
- ❌ 不跨子域,不自动随请求发送
httpOnly Cookie
- ✅ JS 读不到,天然防 XSS
- ✅ 跨域规则由后端控制(SameSite、Secure)
- ❌ 要防 CSRF(需要额外的 CSRF token 或 SameSite 策略)
- ❌ 要后端配合设置 Cookie
in4vue 的选择:localStorage。理由:
- in4vue 是开源笔记站,AI 调用通过 Edge Function 代理,没有传统后端,没有跨域 Cookie 需求
- 前端 SPA 改 localStorage 最快见效,学习曲线平缓
- XSS 风险通过"不用
v-html渲染用户输入 + markdown-it 内置净化"规避
生产环境敏感系统(银行、医疗):优先 httpOnly Cookie,后端配合。
3. Pinia user store:鉴权的大脑
// src/stores/user.ts
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export interface UserInfo {
id: number
username: string
avatar?: string
roles: string[]
}
export const useUserStore = defineStore(
'user',
() => {
const token = ref<string>('')
const info = ref<UserInfo | null>(null)
const isLoggedIn = computed(() => !!token.value)
const hasRole = (role: string) => info.value?.roles.includes(role) ?? false
const setToken = (t: string) => {
token.value = t
}
const setInfo = (u: UserInfo) => {
info.value = u
}
const logout = () => {
token.value = ''
info.value = null
}
return { token, info, isLoggedIn, hasRole, setToken, setInfo, logout }
},
{
// 依赖 pinia-plugin-persistedstate(见 Pinia 笔记 §10)
persist: {
key: 'in4vue-user',
paths: ['token', 'info'],
},
},
)
三个关键点:
isLoggedIn是 computed,token 变化时自动响应(视图跟着更新)persist让刷新页面后 token/用户信息还在hasRole为按钮级权限留好钩子
Java 对照:这就是一个手写的 UserDetailsService + SecurityContext——不过前端的"当前用户"只有一个(浏览器是单租户环境),不用像后端那样区分多会话。
4. 登录接口封装
// src/api/auth.ts
import { request } from './request'
export interface LoginPayload {
username: string
password: string
}
export interface LoginResult {
token: string
user: {
id: number
username: string
avatar?: string
roles: string[]
}
}
export const loginApi = (data: LoginPayload) =>
request.post<LoginResult>('/auth/login', data)
export const logoutApi = () => request.post('/auth/logout')
export const getCurrentUser = () =>
request.get<LoginResult['user']>('/auth/me')
request 是封装好的 axios 实例(见 Axios 笔记)。接口按驼峰命名,符合项目规范。
5. 登录页:表单 + 提交 + 跳转
<!-- src/pages/Login.vue -->
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { loginApi } from '@/api/auth'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const formRef = ref<FormInstance>()
const form = reactive({
username: '',
password: '',
})
const rules: FormRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码至少 6 位', trigger: 'blur' },
],
}
const loading = ref(false)
const onSubmit = async () => {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
const { token, user } = await loginApi(form)
userStore.setToken(token)
userStore.setInfo(user)
// 登录前想去的页面,没有就去首页
const redirect = (route.query.redirect as string) || '/'
await router.replace(redirect)
ElMessage.success(`欢迎回来,${user.username}`)
} catch (err: any) {
ElMessage.error(err?.message || '登录失败')
} finally {
loading.value = false
}
}
</script>
<template>
<div class="flex min-h-dvh items-center justify-center bg-gray-50 dark:bg-gray-900">
<div class="w-full max-w-sm rounded-lg bg-white p-8 shadow dark:bg-gray-800">
<h1 class="mb-6 text-center text-2xl font-bold">登录 in4vue</h1>
<el-form ref="formRef" :model="form" :rules="rules" label-width="0" @keyup.enter="onSubmit">
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="用户名" size="large" />
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="密码"
size="large"
show-password
/>
</el-form-item>
<el-button type="primary" size="large" class="w-full" :loading="loading" @click="onSubmit">
登录
</el-button>
</el-form>
</div>
</div>
</template>
几处细节:
route.query.redirect—— 路由守卫把用户拦到登录页时会带上原路径,登录后回到原位(后面讲)router.replace而不是push—— 不保留登录页在历史栈,避免用户点"返回"又回到登录页@keyup.enter—— 让回车直接提交,不用用户必须点按钮
6. Axios 拦截器:自动带 token + 401 处理
src/api/request.ts(完整版见 Axios 笔记,这里只看鉴权相关):
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
import { useUserStore } from '@/stores/user'
const service = axios.create({
baseURL: '/api',
timeout: 10000,
})
// 请求拦截器:每个请求自动加 Authorization
service.interceptors.request.use(
(config) => {
const userStore = useUserStore()
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}
return config
},
(error) => Promise.reject(error),
)
// 响应拦截器:401 自动登出 + 跳登录页
service.interceptors.response.use(
(response) => response.data,
(error) => {
const status = error.response?.status
const userStore = useUserStore()
if (status === 401) {
userStore.logout()
ElMessage.error('登录已过期,请重新登录')
router.replace({
path: '/login',
query: { redirect: router.currentRoute.value.fullPath },
})
} else if (status === 403) {
ElMessage.error('没有权限访问')
} else {
ElMessage.error(error.response?.data?.message || '请求失败')
}
return Promise.reject(error)
},
)
export const request = service
要点:
- 在 request 拦截器里取 Pinia store —— 每次请求都用最新的 token(store 变了会立刻反映)
- 401 集中处理 —— 所有业务代码不用自己判断"token 过期了怎么办"
query: { redirect: ... }—— 登录后能回原页面
Java 对照:这就是一个 OncePerRequestFilter + @ControllerAdvice 的组合:
- request 拦截器 = 加 token 的 Filter
- response 拦截器 =
@ExceptionHandler(UnauthorizedException)的前端版
7. 路由守卫:页面级拦截
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
const routes = [
{ path: '/login', component: () => import('@/pages/Login.vue') },
{
path: '/',
component: () => import('@/layouts/AdminLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', component: () => import('@/pages/Home.vue') },
{ path: 'notes', component: () => import('@/pages/NoteList.vue') },
{
path: 'admin',
component: () => import('@/pages/Admin.vue'),
meta: { requiresAuth: true, roles: ['admin'] },
},
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// 全局前置守卫
router.beforeEach((to, from) => {
const userStore = useUserStore()
// 1. 不需要登录的页面直接放行
if (!to.matched.some((r) => r.meta.requiresAuth)) {
// 已登录访问登录页,重定向到首页
if (to.path === '/login' && userStore.isLoggedIn) {
return { path: '/' }
}
return true
}
// 2. 需要登录但未登录
if (!userStore.isLoggedIn) {
return {
path: '/login',
query: { redirect: to.fullPath },
}
}
// 3. 角色权限检查
const requiredRoles = to.matched.flatMap((r) => (r.meta.roles as string[]) ?? [])
if (requiredRoles.length > 0) {
const hasAny = requiredRoles.some((role) => userStore.hasRole(role))
if (!hasAny) {
return { path: '/403' }
}
}
return true
})
export default router
几个守卫的含义(Java 类比):
| Vue Router | Spring Security |
|---|---|
beforeEach |
全局 Filter |
meta.requiresAuth |
@PreAuthorize("isAuthenticated()") |
meta.roles |
@PreAuthorize("hasRole('ADMIN')") |
to.matched.some(...) |
检查 URL 匹配的所有过滤规则 |
返回 { path: '/login' } |
AuthenticationEntryPoint.commence() 跳到登录页 |
为什么用 matched 而不是 to.meta:嵌套路由下,父路由的 meta 不会自动合并到子路由。用 matched.some() 遍历整条路由链才完整。
8. 登录后获取当前用户信息
有些场景 token 还有效但用户信息丢了(刷新页面、localStorage 没持久化 info)。在应用启动时拉一次 /auth/me:
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { useUserStore } from '@/stores/user'
import { getCurrentUser } from '@/api/auth'
const app = createApp(App)
app.use(createPinia())
app.use(router)
// 启动时恢复用户状态
const userStore = useUserStore()
if (userStore.token && !userStore.info) {
try {
const user = await getCurrentUser()
userStore.setInfo(user)
} catch {
userStore.logout()
}
}
app.mount('#app')
注意:Pinia 要先 app.use(createPinia()) 才能 useUserStore()。
9. 登出流程
// composables/useAuth.ts
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { logoutApi } from '@/api/auth'
export function useAuth() {
const router = useRouter()
const userStore = useUserStore()
const logout = async () => {
try {
await logoutApi() // 告诉后端销毁 token(可选)
} catch {
// 即便后端调用失败,前端也强制登出
}
userStore.logout()
await router.replace('/login')
}
return { logout }
}
顶栏按钮:
<el-dropdown @command="handleCommand">
<span>{{ userStore.info?.username }}</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<script setup lang="ts">
import { useAuth } from '@/composables/useAuth'
const { logout } = useAuth()
const handleCommand = (cmd: string) => {
if (cmd === 'logout') logout()
}
</script>
10. token 续期(可选进阶)
JWT 通常有过期时间(比如 2 小时)。用户正在操作时 token 到期会被踢,体验很差。两种方案:
方案 A:无感刷新(refresh token)
登录返回两个 token:
access_token(2 小时)—— 每个请求带上refresh_token(7 天)—— 只在刷新时用
响应拦截器里捕获 401,用 refresh_token 换新的 access_token:
let isRefreshing = false
let waitQueue: Array<(token: string) => void> = []
service.interceptors.response.use(
(res) => res.data,
async (error) => {
const originalReq = error.config
const userStore = useUserStore()
if (error.response?.status === 401 && !originalReq._retry) {
originalReq._retry = true
if (isRefreshing) {
// 已经在刷新,排队等新 token
return new Promise((resolve) => {
waitQueue.push((token) => {
originalReq.headers.Authorization = `Bearer ${token}`
resolve(service(originalReq))
})
})
}
isRefreshing = true
try {
const { token } = await refreshApi(userStore.refreshToken)
userStore.setToken(token)
waitQueue.forEach((cb) => cb(token))
waitQueue = []
originalReq.headers.Authorization = `Bearer ${token}`
return service(originalReq)
} catch {
userStore.logout()
router.replace('/login')
return Promise.reject(error)
} finally {
isRefreshing = false
}
}
return Promise.reject(error)
},
)
关键点:
originalReq._retry标记 —— 避免刷新接口自己 401 时死循环waitQueue—— 刷新期间并发的 N 个请求都等这一次刷新完成,不要各刷各的
方案 B:简单粗暴——接近过期时提前刷新
// 在 Pinia store 里
const tokenExpiresAt = ref(0)
watch(token, (t) => {
if (t) {
const { exp } = jwtDecode(t)
tokenExpiresAt.value = exp * 1000
}
})
// 启动定时器,提前 5 分钟刷新
setInterval(() => {
if (tokenExpiresAt.value - Date.now() < 5 * 60 * 1000) {
refreshToken()
}
}, 60 * 1000)
学习项目用方案 A 就够了,方案 B 更稳但代码量大。
11. 常见坑点速查
| 现象 | 原因 | 解法 |
|---|---|---|
| 刷新页面立刻跳回登录 | Pinia 没配 persist,或 persist 没包含 token |
见 Pinia 笔记 §10 |
| Axios 拦截器拿不到 store | 在模块顶层(文件加载时)调 useUserStore() |
放到拦截器函数体内调 |
| 路由守卫无限重定向 | 登录页也配了 requiresAuth |
确保 /login 不被 requiresAuth 覆盖 |
| 401 弹多个错误提示 | 一个页面同时发多个请求都 401 | 用"节流 + 标志位"只处理一次,见下面 |
| 登录成功但页面没刷新 | Pinia 的 info 更新时间晚于页面渲染 |
登录接口必须等 setInfo 完成再 router.replace |
401 只处理一次的技巧
let handling401 = false
service.interceptors.response.use(
(res) => res.data,
(error) => {
if (error.response?.status === 401 && !handling401) {
handling401 = true
userStore.logout()
router.replace('/login').finally(() => {
handling401 = false
})
}
return Promise.reject(error)
},
)
12. 安全提醒
- 永远用 HTTPS:token 明文走 HTTP 等于裸奔
- 不要在前端校验密码复杂度后就不在后端校验——前端校验只是提升体验
- 不要把敏感数据存到 localStorage(身份证号、银行卡号等)
- 避免用
v-html渲染用户输入,防 XSS 偷 token - 生产环境的
console.log不要打 token,日志会被带进 Sentry 等监控
小练习
- 在 in4vue 里建一个假的登录流程:
loginApi用setTimeout模拟,用户名/密码随便填 - 刷新页面看 token 能不能恢复(Pinia 持久化是否生效)
- 手动把 localStorage 里的 token 改成
"invalid",访问任意需要登录的页面,看是否被 Axios 拦截器踢回登录页 - 加一个
/admin页面,meta.roles: ['admin'],用普通账号访问看是否跳 403 - 在登录页按 F5 刷新,登录后看是否回到
redirect参数指向的页面
延伸阅读
- Vue Router - 导航守卫
- JWT 调试器(解码 token 看载荷,debug 神器)
- OWASP - JWT 最佳实践
- Auth0 - Token Storage(token 存哪的权威讨论)
- jwt-decode(纯前端解 JWT 载荷)