第 138 章:用户认证与权限
学习目标
- 掌握 JWT、OAuth、Session 三种认证方式
- 学会在 Vue 3 中实现登录、Token 管理、权限控制
- 理解路由守卫、指令级权限、组件级权限
- 搭建完整的认证授权体系
一、认证 vs 授权
二、常见认证方式
2.1 JWT
typescript
// Token 结构
{
header: { alg: 'HS256', typ: 'JWT' },
payload: { userId: 1, exp: 1699999999 },
signature: 'xxx'
}2.2 Session
typescript
// 服务器创建 session,客户端存 cookie
HTTP/1.1 200 OK
Set-Cookie: SID=abc123; HttpOnly; Secure2.3 OAuth 2.0
1. 跳转到授权服务器
2. 用户授权
3. 回调返回 code
4. 后端用 code 换 token三、JWT 登录
3.1 登录实现
typescript
// composables/useAuth.ts
import { ref, computed } from 'vue';
const token = ref(localStorage.getItem('token') || '');
const user = ref<User | null>(null);
export function useAuth() {
async function login(credentials: LoginDTO) {
const res = await http.post<{ token: string; user: User }>('/auth/login', credentials);
token.value = res.token;
user.value = res.user;
localStorage.setItem('token', res.token);
}
function logout() {
token.value = '';
user.value = null;
localStorage.removeItem('token');
}
async function fetchProfile() {
if (!token.value) return null;
const res = await http.get<User>('/auth/me');
user.value = res;
return res;
}
return { token, user, login, logout, fetchProfile };
}3.2 登录组件
vue
<template>
<form @submit.prevent="handleSubmit">
<input v-model="form.username" placeholder="用户名" />
<input v-model="form.password" type="password" placeholder="密码" />
<button type="submit" :disabled="loading">{{ loading ? '登录中...' : '登录' }}</button>
<p v-if="error" class="error">{{ error }}</p>
</form>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuth } from '@/composables/useAuth';
const router = useRouter();
const { login } = useAuth();
const form = reactive({ username: '', password: '' });
const loading = ref(false);
const error = ref('');
async function handleSubmit() {
loading.value = true;
error.value = '';
try {
await login(form);
router.push('/dashboard');
} catch (e) {
error.value = (e as Error).message;
} finally {
loading.value = false;
}
}
</script>四、Token 管理
4.1 自动添加 token
typescript
http.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});4.2 Token 刷新
typescript
let isRefreshing = false;
let waitQueue: Array<(token: string) => void> = [];
async function refreshToken() {
const refreshToken = localStorage.getItem('refresh-token');
const res = await http.post<{ token: string }>('/auth/refresh', { refreshToken });
return res.token;
}
http.interceptors.response.use(
(res) => res,
async (error) => {
const { config, response } = error;
if (response?.status !== 401 || config._retry) {
return Promise.reject(error);
}
if (isRefreshing) {
return new Promise((resolve) => {
waitQueue.push((token) => {
config.headers.Authorization = `Bearer ${token}`;
resolve(http(config));
});
});
}
config._retry = true;
isRefreshing = true;
try {
const newToken = await refreshToken();
localStorage.setItem('token', newToken);
waitQueue.forEach((cb) => cb(newToken));
waitQueue = [];
config.headers.Authorization = `Bearer ${newToken}`;
return http(config);
} catch (e) {
// 跳转登录
window.location.href = '/login';
return Promise.reject(e);
} finally {
isRefreshing = false;
}
}
);五、Pinia 集成
5.1 Auth Store
typescript
// stores/auth.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('token') || '');
const user = ref<User | null>(null);
const roles = ref<string[]>([]);
const permissions = ref<string[]>([]);
const isLoggedIn = computed(() => !!token.value);
const isAdmin = computed(() => roles.value.includes('admin'));
async function login(credentials: LoginDTO) {
const res = await authApi.login(credentials);
token.value = res.token;
user.value = res.user;
roles.value = res.user.roles || [];
permissions.value = res.user.permissions || [];
localStorage.setItem('token', res.token);
}
function logout() {
token.value = '';
user.value = null;
roles.value = [];
permissions.value = [];
localStorage.removeItem('token');
}
function hasPermission(perm: string) {
return permissions.value.includes(perm) || isAdmin.value;
}
function hasRole(role: string) {
return roles.value.includes(role);
}
return {
token,
user,
roles,
permissions,
isLoggedIn,
isAdmin,
login,
logout,
hasPermission,
hasRole
};
}, {
persist: {
paths: ['token', 'user', 'roles', 'permissions']
}
});六、路由守卫
6.1 基础守卫
typescript
// router/guards.ts
router.beforeEach((to, from) => {
const authStore = useAuthStore();
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
return { name: 'login', query: { redirect: to.fullPath } };
}
return true;
});6.2 角色守卫
typescript
router.beforeEach((to, from) => {
const authStore = useAuthStore();
if (to.meta.roles) {
const roles = to.meta.roles as string[];
const hasRole = roles.some((r) => authStore.hasRole(r));
if (!hasRole) {
return { name: 'forbidden' };
}
}
return true;
});6.3 路由元类型
typescript
// types/router.d.ts
import 'vue-router';
declare module 'vue-router' {
interface RouteMeta {
requiresAuth?: boolean;
roles?: string[];
permissions?: string[];
title?: string;
}
}七、组件级权限
7.1 v-if 控制
vue
<template>
<button v-if="authStore.hasPermission('user:create')" @click="addUser">
新增用户
</button>
</template>
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth';
const authStore = useAuthStore();
</script>7.2 权限组件
vue
<!-- components/Auth.vue -->
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth';
const props = defineProps<{
permission?: string;
role?: string;
}>();
const authStore = useAuthStore();
const allowed = computed(() => {
if (props.permission) {
return authStore.hasPermission(props.permission);
}
if (props.role) {
return authStore.hasRole(props.role);
}
return true;
});
</script>
<template>
<slot v-if="allowed" />
<slot v-else name="fallback" />
</template>vue
<Auth permission="user:create">
<button @click="addUser">新增用户</button>
<template #fallback>
<p>无权限</p>
</template>
</Auth>7.3 多个权限
vue
<Auth :permission="['user:create', 'user:edit']" require="all">
<!-- 同时拥有 -->
</Auth>
<Auth :permission="['user:create', 'user:edit']" require="any">
<!-- 任一拥有 -->
</Auth>八、指令级权限
8.1 定义指令
typescript
// directives/permission.ts
import type { Directive } from 'vue';
import { useAuthStore } from '@/stores/auth';
export const permission: Directive = {
mounted(el, binding) {
const { value } = binding;
const authStore = useAuthStore();
if (value && !authStore.hasPermission(value)) {
el.parentNode?.removeChild(el);
}
}
};8.2 注册
typescript
import { permission } from './directives/permission';
app.directive('permission', permission);8.3 使用
vue
<button v-permission="'user:create'">新增</button>
<button v-permission="'user:delete'">删除</button>九、按钮级权限
vue
<template>
<el-button v-if="canEdit" @click="edit">编辑</el-button>
<el-button v-if="canDelete" @click="del">删除</el-button>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useAuthStore } from '@/stores/auth';
const authStore = useAuthStore();
const canEdit = computed(() => authStore.hasPermission('post:edit'));
const canDelete = computed(() => authStore.hasPermission('post:delete'));
</script>十、菜单权限
vue
<template>
<el-menu>
<el-menu-item v-for="item in visibleMenus" :key="item.path" :index="item.path">
{{ item.title }}
</el-menu-item>
</el-menu>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useAuthStore } from '@/stores/auth';
const authStore = useAuthStore();
const allMenus = [
{ path: '/dashboard', title: '仪表盘', permission: undefined },
{ path: '/users', title: '用户管理', permission: 'user:view' },
{ path: '/settings', title: '系统设置', permission: 'system:view' }
];
const visibleMenus = computed(() =>
allMenus.filter((m) => !m.permission || authStore.hasPermission(m.permission))
);
</script>十一、请求级权限
typescript
// 拦截器
http.interceptors.response.use(
(res) => res,
(error) => {
if (error.response?.status === 403) {
showMessage('无权访问');
router.push('/forbidden');
}
return Promise.reject(error);
}
);十二、OAuth 2.0
12.1 跳转授权
typescript
function oauthLogin() {
const redirectUri = encodeURIComponent('https://my-app.com/oauth/callback');
const clientId = 'my-client-id';
const url = `https://oauth.example.com/authorize?response_type=code&client_id=${clientId}&redirect_uri=${redirectUri}`;
window.location.href = url;
}12.2 回调处理
typescript
// views/OAuthCallback.vue
<script setup lang="ts">
import { onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
const router = useRouter();
const route = useRoute();
const authStore = useAuthStore();
onMounted(async () => {
const code = route.query.code as string;
if (!code) {
router.push('/login');
return;
}
try {
await authStore.oauthLogin(code);
router.push('/dashboard');
} catch (e) {
router.push('/login');
}
});
</script>12.3 后端交换 Token
typescript
// API
async function oauthLogin(code: string) {
const res = await http.post<{ token: string; user: User }>('/auth/oauth', { code });
token.value = res.token;
user.value = res.user;
}十三、安全建议
13.1 Token 存储
typescript
// ❌ localStorage 容易被 XSS 读取
localStorage.setItem('token', token);
// ✅ 用 HttpOnly Cookie(由后端设置)
// ✅ 用 sessionStorage(关闭后失效)
sessionStorage.setItem('token', token);13.2 路由保护
typescript
// 路由守卫 + 路由懒加载
{
path: '/admin',
component: () => import('@/views/Admin.vue'),
meta: { requiresAuth: true, roles: ['admin'] }
}13.3 内容安全策略
html
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'">13.4 防止 CSRF
typescript
// CSRF token
http.interceptors.request.use((config) => {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
if (csrfToken) {
config.headers['X-CSRF-Token'] = csrfToken;
}
return config;
});13.5 限制权限
typescript
// 前端权限只是 UI 控制,实际权限校验必须在后端十四、记住我
typescript
function login(credentials: LoginDTO, remember = false) {
// ...
if (remember) {
localStorage.setItem('token', token);
} else {
sessionStorage.setItem('token', token);
}
}十五、登出
typescript
// 全局登出
async function logout() {
try {
await authApi.logout(); // 通知后端
} finally {
authStore.logout();
router.push('/login');
}
}十六、本章小结
| 概念 | 关键 |
|---|---|
| JWT | Bearer Token + Authorization 头 |
| Token 刷新 | 拦截器 + 等待队列 |
| 路由守卫 | meta + beforeEach |
| 组件权限 | Auth 组件 + 插槽 |
| 指令权限 | v-permission |
| 菜单权限 | computed 过滤 |
| OAuth | 授权码模式 |
| 安全 | HttpOnly Cookie + CSP |
动手练习
- JWT 登录:实现登录、Token 存储、自动刷新
- 路由守卫:实现登录拦截和角色权限
- 组件权限:实现 Auth 组件,根据权限显示内容
- 指令权限:实现 v-permission 指令
- 菜单权限:根据用户权限动态显示菜单
推荐阅读
- 📖 JWT 介绍 — 原理
- 📖 OAuth 2.0 — 授权框架
- 📖 Vue 3 认证 — 路由守卫
- 🌐 OWASP 安全指南 — Web 安全
下一章:第 139 章:国际化 i18n →