skip to content
蕉太狼的博客
目录

前端鉴权方案

鉴权解决两个问题:你是谁(认证)和你能做什么(授权)。前端在这件事里的角色是:持有凭证、携带凭证发请求、处理凭证过期,以及根据权限控制界面。

方案选型的核心依据只有一个:你的系统边界在哪里。单体应用、微服务、第三方登录,每种场景下最优解不同。


一、Cookie / Session

原理

服务端创建 Session,把 Session ID 通过 Set-Cookie 写入浏览器,浏览器后续请求自动携带 Cookie,服务端凭 Session ID 查找用户状态。

登录请求 → 服务端创建 Session → Set-Cookie: sid=xxx
后续请求 → 浏览器自动带 Cookie: sid=xxx → 服务端查 Session → 验证通过

安全配置

Set-Cookie: sid=xxx; HttpOnly; Secure; SameSite=Strict; Max-Age=86400
属性作用
HttpOnlyJS 无法读取(防 XSS 窃取 Cookie)
Secure只在 HTTPS 下传输
SameSite=Strict只在同源请求中携带(防 CSRF)
SameSite=Lax允许 GET 类型的跨站跳转携带(更宽松)
Max-Age有效期(秒),不设则为 Session Cookie(关闭浏览器失效)
// ❌ 前端不要读 Cookie 里的 Session ID 做任何判断
// HttpOnly 使得 JS 根本读不到,这样做毫无意义
const sid = document.cookie.match(/sid=([^;]+)/)?.[1]
if (sid) { ... }
// ✅ Cookie/Session 模式下,前端只需要关心请求是否 401
// 服务端完全控制鉴权逻辑,前端不持有任何凭证
axios.interceptors.response.use(
res => res,
err => {
if (err.response?.status === 401) {
router.push('/login')
}
return Promise.reject(err)
},
)

适用场景

  • 同域单体应用(前后端同域)
  • 对安全要求高的系统(银行、政务)
  • 前后端部署在同域,不涉及跨域请求的场景

二、JWT

结构

JWT 由三部分组成,Base64 编码后用 . 连接:

Header.Payload.Signature
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiIxMjMiLCJleHAiOjE3MDAwMDB9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
  • Header:算法类型(HS256 / RS256
  • Payload:用户信息 + 过期时间(明文,不要放敏感数据)
  • Signature:用密钥对前两部分签名,防篡改

存储位置

// ❌ 方案一:localStorage(有 XSS 风险)
localStorage.setItem('token', jwt)
// JS 可以直接读取,XSS 攻击可以窃取 token
// ❌ 方案二:普通 Cookie(有 CSRF 风险)
// 浏览器自动携带,跨站请求也会带上
// ✅ 推荐:HttpOnly Cookie
// 服务端在登录响应里设置 HttpOnly Cookie,JS 读不到,XSS 无法窃取
// 配合 SameSite=Strict 防 CSRF
// 前端完全不需要管理 token,请求自动携带

如果必须用 localStorage(如跨域 API),至少做好 XSS 防护(CSP + 输出编码)。

Payload 只放必要信息

// ❌ Payload 放了敏感数据,JWT 只是 Base64 编码,任何人都能解码
{
userId: '123',
password: 'hashed_password', // 绝对不能放
phone: '138xxxx', // 敏感信息不能放
}
// ✅ 只放鉴权必需的最小字段
{
sub: '123', // 用户 ID
role: 'admin', // 角色
exp: 1700000000, // 过期时间(Unix 时间戳)
iat: 1699999000, // 签发时间
}

三、Token 无感刷新

Access Token 有效期短(15 分钟到 2 小时),过期后需要用 Refresh Token 换新的 Access Token,这个过程对用户应该是无感的。

核心问题

并发请求时,多个请求同时遇到 401,会同时触发刷新,导致竞争和重复刷新。解决方案是刷新时挂起后续请求,刷新完成后统一重发

src/utils/request.ts
import axios from 'axios'
const http = axios.create({ baseURL: '/api' })
let isRefreshing = false
// 刷新期间挂起的请求队列
const pendingQueue: Array<(token: string) => void> = []
http.interceptors.response.use(
res => res,
async err => {
const originalRequest = err.config
if (err.response?.status !== 401 || originalRequest._retry) {
return Promise.reject(err)
}
if (isRefreshing) {
// 刷新进行中:把当前请求加入等待队列
return new Promise(resolve => {
pendingQueue.push((newToken: string) => {
originalRequest.headers.Authorization = `Bearer ${newToken}`
resolve(http(originalRequest))
})
})
}
originalRequest._retry = true
isRefreshing = true
try {
const { data } = await http.post('/auth/refresh', {
refreshToken: getRefreshToken(),
})
const newToken = data.accessToken
saveToken(newToken)
// 通知队列里所有挂起的请求
pendingQueue.forEach(resolve => resolve(newToken))
pendingQueue.length = 0
// 重发原始请求
originalRequest.headers.Authorization = `Bearer ${newToken}`
return http(originalRequest)
} catch {
// 刷新也失败,强制登出
clearToken()
router.push('/login')
return Promise.reject(err)
} finally {
isRefreshing = false
}
},
)

四、OAuth2 / OIDC

适用场景

  • 接入第三方登录(GitHub、Google、微信)
  • 微服务架构,多个服务共享一个认证中心
  • 需要授权第三方应用访问用户数据

Authorization Code + PKCE 流程

PKCE(Proof Key for Code Exchange)是 OAuth2 在前端(公开客户端)的安全扩展,防止授权码被截获:

src/utils/oauth.ts
// 1. 生成 code_verifier(随机字符串)和 code_challenge(其 SHA256 哈希)
async function generatePKCE() {
const verifier = crypto.randomUUID().replace(/-/g, '') + crypto.randomUUID().replace(/-/g, '')
const encoder = new TextEncoder()
const data = encoder.encode(verifier)
const digest = await crypto.subtle.digest('SHA-256', data)
const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
sessionStorage.setItem('pkce_verifier', verifier)
return { verifier, challenge }
}
// 2. 跳转到授权页
async function startLogin() {
const { challenge } = await generatePKCE()
const state = crypto.randomUUID()
sessionStorage.setItem('oauth_state', state)
const params = new URLSearchParams({
response_type: 'code',
client_id: 'your-client-id',
redirect_uri: `${location.origin}/callback`,
scope: 'openid profile email',
state,
code_challenge: challenge,
code_challenge_method: 'S256',
})
location.href = `https://auth.example.com/authorize?${params}`
}
// 3. 回调页处理 code,换取 token
async function handleCallback() {
const params = new URLSearchParams(location.search)
const code = params.get('code')
const state = params.get('state')
// 验证 state 防止 CSRF
if (state !== sessionStorage.getItem('oauth_state')) {
throw new Error('Invalid state')
}
const verifier = sessionStorage.getItem('pkce_verifier')!
const { data } = await axios.post('https://auth.example.com/token', {
grant_type: 'authorization_code',
code,
redirect_uri: `${location.origin}/callback`,
client_id: 'your-client-id',
code_verifier: verifier,
})
saveToken(data.access_token)
router.push('/')
}

五、权限控制

路由级别:导航守卫

src/router/index.ts
router.beforeEach((to, _from, next) => {
const isLoggedIn = !!getToken()
const requiresAuth = to.meta.requiresAuth
if (requiresAuth && !isLoggedIn) {
next({ path: '/login', query: { redirect: to.fullPath } })
return
}
// 角色权限:页面需要 admin,但当前用户不是 admin
const requiredRole = to.meta.role as string | undefined
const userRole = useAuthStore().role
if (requiredRole && userRole !== requiredRole) {
next('/403')
return
}
next()
})

路由配置:

const routes = [
{
path: '/dashboard',
component: Dashboard,
meta: { requiresAuth: true },
},
{
path: '/admin',
component: AdminPanel,
meta: { requiresAuth: true, role: 'admin' },
},
]

按钮级别:自定义指令

src/directives/permission.ts
import type { Directive } from 'vue'
import { useAuthStore } from '@/stores/auth'
// 用法:v-permission="'user:delete'"
export const vPermission: Directive<HTMLElement, string> = {
mounted(el, binding) {
const authStore = useAuthStore()
const required = binding.value
if (!authStore.permissions.includes(required)) {
// 没权限:移除元素
el.parentNode?.removeChild(el)
}
},
}
<button v-permission="'order:export'">导出订单</button>
<button v-permission="'user:delete'">删除用户</button>

六、方案对比

维度Cookie/SessionJWTOAuth2/OIDC
状态管理服务端有状态无状态无状态
跨域支持较差(需 CORS + withCredentials)好(Header 携带)
Token 撤销直接删 Session,即时生效需要黑名单机制(短有效期只能缩小窗口)通过授权服务器撤销
适合场景同域单体应用前后端分离、移动端第三方登录、多服务共享认证
安全重点SameSite + HttpOnly存储位置 + 短有效期PKCE + state 防 CSRF