Skip to content
第 138 / 250 章前端⏱ 10 分钟阅读

第 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; Secure

2.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');
  }
}

十六、本章小结

概念关键
JWTBearer Token + Authorization 头
Token 刷新拦截器 + 等待队列
路由守卫meta + beforeEach
组件权限Auth 组件 + 插槽
指令权限v-permission
菜单权限computed 过滤
OAuth授权码模式
安全HttpOnly Cookie + CSP

动手练习

  1. JWT 登录:实现登录、Token 存储、自动刷新
  2. 路由守卫:实现登录拦截和角色权限
  3. 组件权限:实现 Auth 组件,根据权限显示内容
  4. 指令权限:实现 v-permission 指令
  5. 菜单权限:根据用户权限动态显示菜单

推荐阅读


下一章第 139 章:国际化 i18n

本站基于 VitePress 构建 · 由 Codebook 团队维护