登录鉴权与路由守卫

1. 前端鉴权的全景图

后端视角里"鉴权"就是 SecurityFilterChain:请求进来,过滤器一层层过,不通过就 401/403。前端也一样,只不过过滤器分散在几个位置:

用户点"登录" → [登录页]
     ↓
  POST /api/login → 拿到 token
     ↓
[Pinia user store] ← 存 token + 用户信息
     ↓
[Axios 拦截器] ← 每个请求自动加 Authorization 头
     ↓
[路由守卫] ← 切页面时检查有没有 token
     ↓
  遇到 401 → 清 token → 跳登录页

四个角色

  1. Pinia store —— 保管 token 和用户信息(类比 SecurityContextHolder
  2. Axios 拦截器 —— 每个请求自动加 token(类比 OncePerRequestFilter
  3. 路由守卫 —— 页面跳转前判断是否已登录(类比 AccessDecisionManager
  4. 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

httpOnly Cookie

in4vue 的选择localStorage。理由:

  1. in4vue 是开源笔记站,AI 调用通过 Edge Function 代理,没有传统后端,没有跨域 Cookie 需求
  2. 前端 SPA 改 localStorage 最快见效,学习曲线平缓
  3. 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'],
    },
  },
)

三个关键点

  1. isLoggedIncomputed,token 变化时自动响应(视图跟着更新)
  2. persist 让刷新页面后 token/用户信息还在
  3. 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>

几处细节


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

要点

Java 对照:这就是一个 OncePerRequestFilter + @ControllerAdvice 的组合:


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:

响应拦截器里捕获 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)
  },
)

关键点

方案 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. 安全提醒

  1. 永远用 HTTPS:token 明文走 HTTP 等于裸奔
  2. 不要在前端校验密码复杂度后就不在后端校验——前端校验只是提升体验
  3. 不要把敏感数据存到 localStorage(身份证号、银行卡号等)
  4. 避免用 v-html 渲染用户输入,防 XSS 偷 token
  5. 生产环境的 console.log 不要打 token,日志会被带进 Sentry 等监控

小练习

  1. 在 in4vue 里建一个假的登录流程:loginApisetTimeout 模拟,用户名/密码随便填
  2. 刷新页面看 token 能不能恢复(Pinia 持久化是否生效)
  3. 手动把 localStorage 里的 token 改成 "invalid",访问任意需要登录的页面,看是否被 Axios 拦截器踢回登录页
  4. 加一个 /admin 页面,meta.roles: ['admin'],用普通账号访问看是否跳 403
  5. 在登录页按 F5 刷新,登录后看是否回到 redirect 参数指向的页面

延伸阅读