登录鉴权与路由守卫

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

延伸阅读