用户登录【双token】

657 阅读8分钟

双token

  1. Access Token(访问令牌):
  • 作用:用于验证用户的身份,携带权限信息,允许用户访问受保护的资源或 API。
  • 生命周期:通常具有较短的有效期(例如几分钟到几十分钟)。这是为了减少被盗用后带来的风险。
  • 使用场景:每次客户端(例如 Web 应用或移动应用)发送请求时,都会携带 Access Token 以证明其合法性,服务端验证该 Token 是否有效并决定是否允许访问。
    1. Refresh Token(刷新令牌):
  • 作用:用于刷新 Access Token。当 Access Token 过期时,客户端可以使用 Refresh Token 向服务端请求新的 Access Token,而不需要重新登录。
  • 生命周期:相比 Access Token,它的有效期更长(例如几天到几周)。但因为它的权限较大(可以生成新的 Access Token),它的安全性要求更高。
  • 使用场景:当 Access Token 失效时,客户端发送 Refresh Token 请求新的 Access Token,而不是要求用户重新登录。
  • 双token的流程

    1、用户登录:用户提供正确的凭证(如用户名和密码)后,服务端生成一个 Access Token 和一个 Refresh Token,发送给客户端。

    2、请求带 Access Token:客户端每次请求时会携带 Access Token,服务端验证 Token 是否有效。如果有效,则允许访问受保护资源。

    3、Access Token 过期:由于 Access Token 的生命周期较短,当它过期后,客户端无法再用它访问资源。

    4、使用 Refresh Token 刷新 Access Token:客户端检测到 Access Token 过期后,向服务端发送 Refresh Token,请求新的 Access Token。

    5、返回新的 Access Token:如果 Refresh Token 仍然有效,服务端会返回一个新的 Access Token(以及可能更新的 Refresh Token),客户端继续使用新的 Access Token 进行请求。

    6、Refresh Token 过期:当 Refresh Token 过期时,用户需要重新登录,以获得新的 Access Token 和 Refresh Token。

    1726484545187.png

    为什么要使用双token

    使用双 Token 机制主要是出于以下几个目的:提高安全性提升用户体验减轻服务器负担。具体来说,双 Token 的设计有以下优势:

    1. 提高安全性

    • 短期的 Access Token 减少了被盗用的风险: Access Token 的有效期较短(通常几分钟到几小时),即使被攻击者窃取,也只能在有限的时间内使用它。一旦过期,攻击者无法继续利用它,降低了风险。
    • Refresh Token 受保护更好: Refresh Token 通常有效期较长(例如几天或几周),但其使用频率远低于 Access Token,因此暴露的机会更少。用户的客户端只在 Access Token 过期时才会使用 Refresh Token 请求新的 Access Token,降低了 Refresh Token 被拦截的可能性。

    2. 提升用户体验

    • 避免频繁重新登录: 如果只使用一个短期有效的 Token,那么每当 Token 过期后,用户就需要重新登录。使用双 Token 机制,当 Access Token 过期时,客户端可以自动使用 Refresh Token 获取新的 Access Token,用户无需感知这一过程,从而提供了更加流畅的使用体验。
    • 无感知的 Token 刷新: 双 Token 机制允许在后台自动刷新 Access Token。用户的登录状态可以保持较长时间,避免打断操作或要求频繁输入密码。

    3. 减轻服务器负担

    • 减少用户登录次数: 如果每次 Access Token 失效后都要求用户重新登录,服务器需要频繁处理用户的登录请求。而双 Token 机制允许用户只需在初次登录时验证一次凭证,之后通过 Refresh Token 自动续期,减少了对登录系统的压力。
    • 降低密码验证频率: 双 Token 机制减少了频繁验证用户名和密码的需求,从而减轻了服务器的压力。只有当 Refresh Token 也失效时,用户才需要重新登录。

    4. 更灵活的 Token 管理

    • 更好的控制权限: Access Token 可以嵌入用户的权限信息,因此短期的 Access Token 能方便地进行权限的动态调整。例如,如果某个用户的权限发生了变化,系统可以通过颁发新 Token 反映这些变化,过期的旧 Token 会自然失效。
    • 强制失效机制: 如果检测到某个用户的 Access Token 或 Refresh Token 被盗,服务器可以通过黑名单或撤销 Token 的方式立即使其失效。而 Refresh Token 允许在不影响用户体验的情况下更新 Access Token,实现更加灵活的会话管理。

    5. 提升安全性和便捷性的平衡

    • 短期的 Access Token 与长期的会话状态: 双 Token 机制在安全性和用户体验之间找到了平衡点。Access Token 的短期有效期可以限制 Token 被盗后的风险,而 Refresh Token 提供了长时间保持登录状态的能力,让用户不必频繁重新登录。

    6. 典型应用场景

    • 单页应用(SPA) :如 Vue.js 或 React.js 这样的前端应用,需要频繁与后端 API 进行交互。如果只使用短期的 Access Token,用户的会话可能会频繁中断。通过双 Token 机制,Access Token 失效时可以无感刷新,从而提升用户体验。
    • 移动应用:移动应用用户通常期望登录后长时间保持会话状态。双 Token 机制允许用户长时间使用应用,同时仍保持较高的安全性。

    实例(vue+pinia)

    1. 项目准备 (安装依赖)

    npm install axios

    npm install pinia

    npm install vue-router@4

    |--mock
       |--index.ts
    |-- src
      |--router
           |--index.ts
       |--service
           |--api.ts
       |--store
           |--auth.ts
           |--index.ts
       |--views
           |--Dashboard.vue
           |--Login.vue
       |--App.vue
       |--main.ts
    

    (1)模拟后端mock

    import { MockMethod } from 'vite-plugin-mock'
    const PostUserLogin  = {
        "username": "a",
        "password": "1",
        "accessToken":'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVc2VySWQiOjMzODMxODY4OTA2MzQ0NDQ4LCJpc3MiOiJ6dHkiLCJleHAiOjE3MjIwMDg2MzMsImlhdCI6MTcyMTQwMzgzM30.52S54luvyzc3SwB-M3oClgFPgjzXcJrkFjYIcZ8A7ug',
        "refreshToken":'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVc2VySWQiOjMzODMxODY4OTA2MzQ0NDQ4LCJpc3MiOiJ6dHkiLCJleHAiOjE3MjIwMDg2MzMsImlhdCI6MTcyMTQwMzgzM30.52S54luvyzc3SwB-M3oClgFPgjzXcJrkFjYIcZ8A7ug',
        "accessTokenExpiry": 30  * 60 * 1000,  
        "refreshTokenExpiry": 7 * 30 * 24 * 60 * 1000
    }
    
    const UpdateUserToken  = {
        "accessToken":'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVc2VySWQiOjMzODMxODY4OTA2MzQ0NDQ4LCJpc3MiOiJ6dHkiLCJleHAiOjE3MjIwMDg2MzMsImlhdCI6MTcyMTQwMzgzM30.52S54luvyzc3SwB-M3oClgFPgjzXcJrkFjYIcZ8A7ug',
        "refreshToken":'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVc2VySWQiOjMzODMxODY4OTA2MzQ0NDQ4LCJpc3MiOiJ6dHkiLCJleHAiOjE3MjIwMDg2MzMsImlhdCI6MTcyMTQwMzgzM30.52S54luvyzc3SwB-M3oClgFPgjzXcJrkFjYIcZ8A7ug',
        "accessTokenExpiry": 30  * 60 * 1000,  
        "refreshTokenExpiry": 7 * 30 * 24 * 60 * 1000
    }
    
    function validateCredentials(username, password) {  
      return username === 'a' && password === '1';  
    }  
      
    const mockLogin = (options) => {  
      const { body } = options;  
      if (!body || !body.username || !body.password) {  
        return { code: 400, msg: "缺少用户名或密码" };  
      }  
      
      if (validateCredentials(body.username, body.password)) {  
        return {  
          code: 200,  
          data: PostUserLogin,  
          msg: "登录成功"  
        };  
      } else {  
        return {  
          code: 401,  
          msg: "用户名或密码错误"  
        };  
      }  
    };  
      
    const mockRefresh = () => {  
      return {  
        code: 200,  
        data: UpdateUserToken,  
        msg: "更新双token成功"  
      };  
    };  
      
    export default [  
      {  
        url: '/api/auth/login',  
        method: 'post',  
        response: mockLogin  
      },  
      {  
        url: '/api/auth/refresh',  
        method: 'post',  
        response: mockRefresh  
      }
    ] as MockMethod[];
    

    (2)配置@别名

    import { defineConfig } from "vite";
    import path from "path";
    import vue from "@vitejs/plugin-vue";
    const pathSrc = path.resolve(__dirname, "src");
    
    // https://vitejs.dev/config/
    export default defineConfig({
      plugins: [
        vue(),
      ],
      resolve: {
        alias: {
          "@": pathSrc,
        },
      },
    });
    
    {
      "compilerOptions": {
        "target": "ES2022",
        "lib": ["ES2023"],
        "module": "ESNext",
        "skipLibCheck": true,
    
        /* Bundler mode */
        "moduleResolution": "bundler",
        "allowImportingTsExtensions": true,
        "isolatedModules": true,
        "moduleDetection": "force",
        "noEmit": true,
    
        /* Linting */
        "strict": true,
        "noUnusedLocals": true,
        "noUnusedParameters": true,
        "noFallthroughCasesInSwitch": true,
    
        // 别名
        "baseUrl": ".",
        "paths": {
          "@/*":["src/*"]
        },
      },
      "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
      "references": [{ "path": "./tsconfig.node.json" }]
    }
    

    (3)全局配置

    <script setup lang="ts">
    </script>
    
    <template>
      <RouterView></RouterView>
    </template>
    
    <style scoped>
    </style>
    
    import { createApp } from 'vue'
    import App from './App.vue'
    import {router} from "@/router";
    import { setupStore } from "@/store";
    createApp(App).use(setupStore).use(router).mount('#app')
    

    (3)路由

    import { createRouter, createWebHashHistory } from "vue-router";  
    import login from '@/views/Login.vue';  
    import dashboard from '@/views/Dashboard.vue'; // 假设你有一个 Dashboard.vue 组件  
      
    const routes = [  
        {  
            path: '/',  
            redirect: '/login' // 初始重定向到登录页面  
        },  
        {  
            path: '/login',  
            name: 'Login',  
            component: login,  
        },  
        {  
            path: '/dashboard',  
            name: 'Dashboard',  
            component: dashboard,  
            meta: { requiresAuth: true } // 可以添加一个 meta 字段来标记需要认证的路由  
        },  
        // 你可以继续添加其他路由  
    ];  
      
    const router = createRouter({  
        history: createWebHashHistory(),  
        routes,  
    });  
      
    router.beforeEach((to, from, next) => {  
        const isLogin = Boolean(localStorage.getItem("token"));  
      
        // 检查目标路由是否需要认证  
        if (to.matched.some(record => record.meta.requiresAuth)) {  
            // 需要认证但用户未登录,则重定向到登录页面  
            if (!isLogin) {  
                next({  
                    name: "Login",  
                    query: { redirect: to.fullPath } // 将要跳转路由的 path 作为参数,传递到登录页面  
                });  
            } else {  
                // 用户已登录,继续到目标路由  
                next();  
            }  
        } else {  
            // 路由不需要认证,或者用户已登录,继续到目标路由  
            next();  
      
            // 如果想要用户登录后自动跳转到 dashboard(即使他们尝试访问其他不需要认证的路由),可以在这里添加逻辑  
            // 例如:如果已登录且目标不是 dashboard,则重定向到 dashboard  
            if (isLogin && to.name !== 'Dashboard') {  
                next({ name: 'Dashboard' });  
            }  
        }  
    });  
      
    export { router };
    

    (3)响应拦截和请求拦截

    import axios from 'axios';
    import { useAuthStore } from '@/store/auth';
    
    export const api = axios.create({
      baseURL: '/api',
      timeout: 10000,
    });
    
    // 请求拦截器:添加 Authorization 头
    api.interceptors.request.use(
      (config) => {
        const authStore = useAuthStore();
        if (authStore.token) {
          config.headers.Authorization = `Bearer ${authStore.token}`;
        }
        return config;
      },
      (error) => Promise.reject(error)
    );
    
    // 响应拦截器:处理 401 错误并刷新 Token
    api.interceptors.response.use(
      (response) => response,
      async (error) => {
        const authStore = useAuthStore();
        const originalRequest = error.config;
    
        // 如果是 401 Unauthorized,并且未重试过
        if (error.response && error.response.status === 401 && !originalRequest._retry) {  
          originalRequest._retry = true;
          if (!authStore.isRefreshTokenExpired) {
            try {
              await authStore.refreshAccessToken(); // 刷新 Access Token
              return api(originalRequest); // 重新发送原始请求
            } catch (error) {
              authStore.logout(); // 刷新失败则注销
            }
          } else {
            authStore.logout(); // Refresh Token 过期,直接注销
          }
        }
    
        return Promise.reject(error);
      }
    );
    

    (4)双token使用

    import { defineStore } from 'pinia';  
    import { api } from '@/service/api';  
      
    export const useAuthStore = defineStore('auth', {  
      state: () => ({  
        token: '',  
        refreshToken: '',  
        tokenExpiry:0,  
        refreshTokenExpiry:0,  
      }),  
      getters: {  
        // 计算属性来判断 refreshToken 是否过期  
        isRefreshTokenExpired: (state) => {  
          return new Date().getTime() > state.refreshTokenExpiry;  
        },  
      }, 
      actions: {  
        async login(username: string, password: string) {    
          try {  
            const response = await api.post('/auth/login', { username, password });    
            if (response.data.code == 200) {  
              this.updateTokens(response.data);    
              this.persistTokens();    
              return {   
                token: response.data.data.accessToken,   
                refreshToken: response.data.data.refreshToken  
              };    
            } else {  
              console.log('账号或密码错误');  
              return null;  
            }  
          } catch (error) {    
            console.error('Error refreshing token:', error);    
            this.logout();    
            throw error;    
          }    
        },  
      
        logout() {  
          this.resetTokens();  
          this.clearPersistedTokens();  
          console.log('login out');  
        },  
      
        async refreshAccessToken() {  
          try {  
            const refreshToken = this.refreshToken || localStorage.getItem('refreshToken');  
            if (!refreshToken) {  
              throw new Error('No refresh token available');  
            }  
    
           const response = await api.post<{ code: number; data: TokenData }>('/auth/login', { username, password });  
            if (response.data.code === 200) {  
              // 将秒转换为毫秒  
              const { accessToken, refreshToken, expiresIn, refreshExpiresIn } = response.data.data;  
              this.updateTokens({  
                token: accessToken,  
                refreshToken,  
                tokenExpiry: Date.now() + expiresIn * 1000,  
                refreshTokenExpiry: Date.now() + refreshExpiresIn * 1000,  
              });  
              this.persistTokens();  
              console.log('Login successful');  
              return { token: accessToken, refreshToken };  
            } else {  
              console.log('账号或密码错误');  
              return null;  
            }  
          } catch (error) {  
            console.error('Error logging in:', error);  
            this.logout();  
            throw error;  
          }  
        },  
      
        // 封装更新 tokens 的逻辑  
        updateTokens(tokens: {  
          token: string;  
          refreshToken: string;  
          tokenExpiry: number; // 毫秒为单位  
          refreshTokenExpiry: number; // 毫秒为单位  
        }) {  
          this.token = tokens.token;  
          this.refreshToken = tokens.refreshToken;  
          this.tokenExpiry = tokens.tokenExpiry;  
          this.refreshTokenExpiry = tokens.refreshTokenExpiry;  
        },  
      
        // 封装持久化 tokens 的逻辑  
        persistTokens() {  
          localStorage.setItem('token', this.token);  
          localStorage.setItem('refreshToken', this.refreshToken);  
          localStorage.setItem('token_expiry', this.tokenExpiry);  
          localStorage.setItem('refreshToken_expiry', this.refreshTokenExpiry);  
        },  
      
        // 封装重置 tokens 的逻辑  
        resetTokens() {  
          this.token = '';  
          this.refreshToken = '';  
          this.tokenExpiry = 0;  
          this.refreshTokenExpiry = 0;  
        },  
      
        // 封装清除持久化 tokens 的逻辑  
        clearPersistedTokens() {  
          localStorage.removeItem('token');  
          localStorage.removeItem('refreshToken');  
          localStorage.removeItem('token_expiry');
          localStorage.removeItem('refreshToken_expiry'); 
        }
      },  
    });
    

    (5)配置pinia

    import type { App } from "vue";
    import { createPinia } from "pinia";
    
    const store = createPinia();
    
    export function setupStore(app: App<Element>) {
      app.use(store);
    }
    
    export { store };
    

    (6)页面

    <template>
        <div>
          <h1>登录</h1>
          <form @submit.prevent="handleLogin">
            <input v-model="username" placeholder="用户名" required />
            <input v-model="password" type="password" placeholder="密码" required />
            <button type="submit">登录</button>
          </form>
        </div>
      </template>
      
      <script setup>
      import { ref } from 'vue';
      import { useAuthStore } from '@/store/auth';
      import { useRouter } from 'vue-router';
      
      const username = ref('');
      const password = ref('');
      const authStore = useAuthStore();
      const router = useRouter();
      
      const handleLogin = async () => {    
      try {    
        const res = await authStore.login(username.value, password.value); 
        if (res) {  
          const redirect = router.currentRoute.value.query.redirect || '/dashboard';  
          router.push(redirect);  ;    
        } else {  
          console.error('登录失败');  
        }  
      } catch (error) {    
        console.error('登录失败:', error);    
      }    
    };
      </script>
      
    
    <script setup lang='ts'>
    </script>
    <template>
    <div>成功登录页面</div>
    </template>
    <style scoped></style>