告別頻繁登錄!Nuxt3 + TypeScript + Vue3實戰:雙Token無感刷新方案全解析

前言

在現代 Web 應用中,身份認證是保障系統安全的重要環節。傳統的單 Token 認證方式存在諸多不足,如 Token 過期后需要用戶重新登錄,影響用戶體驗。本文將詳細介紹如何在 Nuxt3 + TypeScript + Vue3 項目中實現無感刷新 Token 機制,通過 Access Token 和 Refresh Token 的雙 Token 架構,既保證了安全性,又提升了用戶體驗。
在這里插入圖片描述
在這里插入圖片描述

一、行業痛點與方案對比

1. 傳統方案的問題

問題類型具體表現用戶影響
頻繁重新登錄Token過期需手動刷新用戶體驗差,流失率+35%
安全風險單一Token長期有效被竊取風險高
性能瓶頸每次請求都驗證完整Token系統延遲增加20%

2. 主流方案對比

31%18%25%27%各方案用戶滿意度對比雙Token方案單Token方案Session方案OAuth方案

二、架構設計與核心原理

1. 系統架構圖

客戶端網關層認證服務業務服務登錄請求(用戶名+密碼)返回雙Token(access+refresh)API請求(帶accessToken)Token驗證驗證結果轉發請求返回數據客戶端網關層認證服務業務服務

2. 雙Token工作流程

  1. 首次認證
  • 用戶提交憑證 → 獲取accessToken(1h) + refreshToken(7d)
  1. 正常請求
// 典型請求頭
headers: {
'Authorization': `Bearer ${accessToken}`,
'X-Request-ID': uuidv4() // 防止重放攻擊
}
  1. Token刷新
  2. 用戶使用憑證登錄,獲取雙 Token
  3. 每次請求攜帶 Access Token
  4. Access Token 過期時,自動使用 Refresh Token 獲取新 Token
  5. 刷新成功后繼續原請求,用戶無感知
  6. Refresh Token 過期或無效時,強制用戶重新登錄

二、后端實現

1. 數據庫設計

CREATE TABLE refresh_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_table_id INT NOT NULL,
user_id VARCHAR(10) NOT NULL,
token VARCHAR(255) NOT NULL,
expires_at DATETIME NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
revoked TINYINT(1) UNSIGNED ZEROFILL DEFAULT 0,
FOREIGN KEY (user_table_id, user_id) REFERENCES user(id, user_id) ON DELETE CASCADE
);

2. 封裝db (server/utils/db.ts)

import mysql from 'mysql2'
// 修改后的QueryResult接口
interface QueryResult<T = any> {results: T extends mysql.RowDataPacket[] ? T :T extends mysql.OkPacket ? [T] :any[]  // 最終回退到any[]確保兼容fields?: mysql.FieldPacket[]
}const pool = mysql.createPool({host: process.env.DB_HOST || 'localhost',user: process.env.DB_USER || 'root',password: process.env.DB_PASSWORD || '12345678',database: process.env.DB_NAME || 'moten',port: parseInt(process.env.DB_PORT || '3306'),waitForConnections: true,connectionLimit: 10,queueLimit: 0
})const promisePool = pool.promise()// 修改后的查詢方法(添加類型斷言)
export const query = async <T = any>(sql: string, values?: any[]): Promise<QueryResult<T>> => {const [result, fields] = await promisePool.query(sql, values)return {results: (Array.isArray(result) ? result : [result]) as T extends mysql.RowDataPacket[] ? T :T extends mysql.OkPacket ? [T] : any[],fields}
}// 執行方法保持不變
export const execute = async (sql: string, values?: any[]): Promise<mysql.OkPacket> => {const [result] = await promisePool.query<mysql.OkPacket>(sql, values)return result
}// 錯誤處理封裝
export async function daoErrorHandler<T>(fn: () => Promise<T>): Promise<T> {try {return await fn()} catch (error: any) {console.error('Database error:', error)throw createError({statusCode: 500,message: 'Database operation failed',data: {code: error.code,sqlMessage: error.sqlMessage}})}
}export default pool

3. 登錄接口實現 (server/api/login.post.ts)

import jwt from 'jsonwebtoken'
import { query } from '../utils/db'
import type { OkPacket, RowDataPacket } from 'mysql2'
import { ResponseCode, sendSuccessResponse, sendErrorResponse } from '~/server/utils/response'// 從環境變量獲取密鑰
const JWT_SECRET = process.env.JWT_SECRET || 'your-strong-secret-key-here'
const ACCESS_TOKEN_EXPIRES_IN = '1h'
const REFRESH_TOKEN_EXPIRES_IN = '7d'// 存儲 Refresh Token
async function storeRefreshToken(userId: number, token: string): Promise<void> {try {const decoded = jwt.decode(token) as {exp?: number,userId?: number,user_id?: string} | nullif (!decoded || !decoded.userId || !decoded.user_id) {throw new Error('無效的令牌格式')}let expiresAt: Dateif (decoded.exp) {expiresAt = new Date(decoded.exp * 1000)} else {expiresAt = new Date()expiresAt.setDate(expiresAt.getDate() + 7)}const sql = `INSERT INTO refresh_tokens (user_table_id, user_id, token, expires_at) VALUES (?, ?, ?, ?)ON DUPLICATE KEY UPDATEtoken = VALUES(token),expires_at = VALUES(expires_at),revoked = 0`const params = [decoded.userId,decoded.user_id,token,expiresAt]const { results } = await query<OkPacket>(sql, params)const result = results[0]if (result.affectedRows === 0) {throw new Error('未能存儲刷新令牌')}} catch (error) {console.error('存儲刷新令牌失敗:', error)throw new Error('無法存儲刷新令牌')}
}export default defineEventHandler(async (event) => {const body = await readBody(event)const { username, password } = body// 參數驗證if (!username || !password) {return sendErrorResponse(event, ResponseCode.BAD_REQUEST, '需要用戶名和密碼')}// 查詢用戶const sql = `SELECT u.id, u.user_id, u.username, u.password, u.disable, r.role_id, r.role FROM user u LEFT JOIN role r ON u.role_id = r.role_id WHERE u.username = ? LIMIT 1`const { results } = await query<RowDataPacket[]>(sql, [username])const user = results[0]if (!user || user.password !== password) {return sendErrorResponse(event, ResponseCode.UNAUTHORIZED, '用戶名或密碼錯誤')}if (user.disable) {return sendErrorResponse(event, ResponseCode.FORBIDDEN, '賬號已禁用')}// 生成兩種 Tokenconst accessToken = jwt.sign({userId: user.id,user_id: user.user_id,role: user.role},JWT_SECRET,{ expiresIn: ACCESS_TOKEN_EXPIRES_IN })const refreshToken = jwt.sign({userId: user.id,user_id: user.user_id},JWT_SECRET + '_REFRESH',{ expiresIn: REFRESH_TOKEN_EXPIRES_IN })// 存儲 Refresh Tokentry {await storeRefreshToken(user.id, refreshToken)} catch (error) {console.error('存儲刷新令牌失敗:', error)return sendErrorResponse(event,ResponseCode.UNAUTHORIZED,'無法完成登錄')}return sendSuccessResponse(event, {accessToken,refreshToken,user: {id: user.id,user_id: user.user_id,username: user.username,role: user.role}}, '登錄成功')
})

4. 刷新 Token 接口 (server/api/auth/refresh.post.ts)

import { ResponseCode, sendSuccessResponse, sendErrorResponse } from '~/server/utils/response'
import { query } from '~/server/utils/db'
import type { OkPacket, RowDataPacket } from 'mysql2'
import jwt from 'jsonwebtoken'const JWT_SECRET = process.env.JWT_SECRET || 'your-strong-secret-key-here'
const REFRESH_SECRET = JWT_SECRET + '_REFRESH'async function validateRefreshToken(userId: number, token: string): Promise<boolean> {try {const sql = `SELECT id FROM refresh_tokens WHERE user_id = ? AND token = ? AND expires_at > NOW() AND revoked = 0LIMIT 1`const { results } = await query<RowDataPacket[]>(sql, [userId, token])if (!results || results.length === 0) {return false}const revokeSql = 'UPDATE refresh_tokens SET revoked = 1 WHERE id = ?'await query<OkPacket>(revokeSql, [results[0].id])return true} catch (error) {console.error('驗證刷新令牌失敗:', error)return false}
}export default defineEventHandler(async (event) => {const body = await readBody(event)const { refreshToken } = bodyif (!refreshToken) {return sendErrorResponse(event, ResponseCode.BAD_REQUEST, '需要 refreshToken')}try {const decoded = jwt.verify(refreshToken, REFRESH_SECRET) as jwt.JwtPayload & { userId?: number, user_id?: string }if (!decoded.userId || !decoded.user_id) {return sendErrorResponse(event, ResponseCode.UNAUTHORIZED, '無效的 refreshToken')}const isValid = await validateRefreshToken(decoded.userId, refreshToken)if (!isValid) {return sendErrorResponse(event, ResponseCode.UNAUTHORIZED, '無效的 refreshToken')}const sql = `SELECT * FROM user WHERE user_id = ?`const { results } = await query<RowDataPacket[]>(sql, [decoded.userId])const user = results[0]if (!user) {return sendErrorResponse(event, ResponseCode.NOT_FOUND, '用戶不存在')}const newAccessToken = jwt.sign({ userId: user.id, user_id: user.user_id, role: user.role },JWT_SECRET,{ expiresIn: '1h' })const newRefreshToken = jwt.sign({ userId: user.id, user_id: user.user_id },REFRESH_SECRET,{ expiresIn: '7d' })// 存儲新的 refreshTokenconst storeSql = `INSERT INTO refresh_tokens (user_table_id, user_id, token, expires_at)VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 7 DAY))`await query<OkPacket>(storeSql, [user.id, user.user_id, newRefreshToken])return sendSuccessResponse(event, {accessToken: newAccessToken,refreshToken: newRefreshToken}, 'Token 刷新成功')} catch (err) {console.error('刷新 Token 失敗:', err)return sendErrorResponse(event,ResponseCode.UNAUTHORIZED,'無效或過期的 refreshToken')}
})

5. 中間件 (server/middleware/auth.ts)

import jwt from 'jsonwebtoken'
const { verify } = jwt
import { sendErrorResponse, ResponseCode } from '../utils/response'const jwtSecret = process.env.JWT_SECRET || 'your-secret-key'interface JwtPayloadWithRole extends jwt.JwtPayload {role: string
}const NO_AUTH_ROUTES: { path: string; method: string }[] = [{ path: '/api/login', method: 'ANY' },{ path: '/api/register', method: 'ANY' },{ path: '/api/contactEmail', method: 'ANY' },{ path: '/api/page-data', method: 'ANY' },{ path: '/api/contact', method: 'ANY' },{ path: '/api/about-us', method: 'ANY' },{ path: '/api/upload', method: 'ANY' },{ path: '/api/page-admin', method: 'GET' }, // 只放行 GET{ path: '/api/auth/refresh', method: 'POST' }, // 添加刷新端點]export default defineEventHandler(async (event) => {// 2. 檢查是否需要鑒權const reqPath = event.pathconst reqMethod = event.node.req.method// 判斷是否在無需鑒權的接口和方法中if (NO_AUTH_ROUTES.some(route =>route.path === reqPath &&(route.method === reqMethod || route.method === 'ANY'))) {return}if (!event.path?.startsWith('/api')) {return}// Skip auth routesif (event.path?.startsWith('/api/auth')) {return}// 2. 檢查是否包含tokenconst authHeader = getHeader(event, 'Authorization')const token = authHeader?.startsWith('Bearer ')? authHeader.split(' ')[1]: authHeader || getCookie(event, 'auth_token')// console.log('Token:', token) // Debugif (!token) {// 對于API請求返回JSON錯誤if (event.path?.startsWith('/api')) {return sendErrorResponse(event,ResponseCode.UNAUTHORIZED,'Authentication required')}// 對于頁面請求重定向到登錄頁return sendRedirect(event, '/login')}try {const decoded = verify(token, jwtSecret) as JwtPayloadWithRoleevent.context.user = decoded// 3. 檢查權限if (decoded?.role !== 'admin' && event.path?.startsWith('/api/admin')) {return sendErrorResponse(event,ResponseCode.FORBIDDEN,'Insufficient permissions')}} catch (err) {console.error('JWT Verification Failed:', err) // 打印具體錯誤return sendErrorResponse(event,ResponseCode.UNAUTHORIZED,'Invalid or expired token')}
})

6. interface

types/middleware/mysql.d.ts

declare module 'mysql2/promise' {interface OkPacket {affectedRows: numberinsertId: numberwarningStatus: numbermessage?: string}interface RowDataPacket {[column: string]: any[column: number]: any}interface ResultSetHeader {fieldCount: numberaffectedRows: numberinsertId: numberinfo?: stringserverStatus?: numberwarningStatus?: number}
}

types/middleware/user.ts

export interface User {user_id: stringusername: stringrole: stringdisable?: numbercreate_time?: string
}export interface UserListResponse {data: {list: User[]}pagination: {currentPage: numberperPage: numbertotal: numbertotalPages: number}
}export interface LoginResponse {code: numbertoken: stringuser: {user_id: stringusername: stringrole: string}
}

7. 錯誤處理 (server/utils/response.ts)

import type { H3Event } from 'h3'export enum ResponseCode {SUCCESS = 200,    // 請求成功BAD_REQUEST = 400, // 請求錯誤UNAUTHORIZED = 401, // 未授權FORBIDDEN = 403,  // 禁止訪問NOT_FOUND = 404,  // 未找到CONFLICT = 409,    // 沖突INTERNAL_ERROR = 500, // 服務器錯誤
}interface ApiResponse<T = any> {code: ResponseCodemessage: stringdata?: Ttimestamp: numbersuccess: boolean
}export function sendSuccessResponse<T>(event: H3Event, data?: T, message: string = '操作成功'): ApiResponse<T> {setResponseStatus(event, ResponseCode.SUCCESS)return {code: ResponseCode.SUCCESS,message,data,timestamp: Date.now(),success: true}
}export function sendErrorResponse(event: H3Event, code: ResponseCode, message: string, errors?: any): ApiResponse {setResponseStatus(event, code)return {code,message,timestamp: Date.now(),success: false,...(errors && { errors })}
}

三、前端實現

1. Token 存儲工具 (utils/storage.ts)

// utils/storage.ts/*** LocalStorage 封裝工具類* 提供類型安全的 localStorage 操作方法*/
class StorageUtil {/*** 存儲數據* @param key 存儲鍵名* @param value 存儲值* @param options 配置選項*/static set<T>(key: string, value: T, options?: { expires?: number }): void {if (typeof window === 'undefined') return;try {const storageData = {value,_timestamp: Date.now(),_expires: options?.expires,};localStorage.setItem(key, JSON.stringify(storageData));} catch (error) {console.error('LocalStorage set error:', error);throw new Error('LocalStorage is not available');}}/*** 獲取數據* @param key 存儲鍵名* @param defaultValue 默認值(可選)* @returns 存儲的值或默認值*/static get<T>(key: string, defaultValue?: T): T | undefined {// 添加服務器端檢查if (typeof window === 'undefined') return defaultValue;try {const item = localStorage.getItem(key);if (!item) return defaultValue;const parsedData = JSON.parse(item) as {value: T;_timestamp: number;_expires?: number;};// 檢查是否過期if (parsedData._expires &&Date.now() > parsedData._timestamp + parsedData._expires) {this.remove(key);return defaultValue;}return parsedData.value;} catch (error) {console.error('LocalStorage get error:', error);return defaultValue;}}/*** 刪除數據* @param key 存儲鍵名*/static remove(key: string): void {localStorage.removeItem(key);}/*** 清空所有數據*/static clear(): void {localStorage.clear();}/*** 檢查是否存在某個鍵* @param key 存儲鍵名*/static has(key: string): boolean {return localStorage.getItem(key) !== null;}/*** 獲取所有鍵名*/static keys(): string[] {return Object.keys(localStorage);}/*** 獲取存儲的數據大小(KB)*/static getSize(): number {let total = 0;for (const key in localStorage) {if (localStorage.hasOwnProperty(key)) {const item = localStorage.getItem(key);total += item ? item.length * 2 : 0; // 每個字符按2字節計算}}return total / 1024; // 轉換為KB}
}export default StorageUtil;

2. API 請求封裝 (composables/useApi.ts)

import type { NitroFetchRequest } from 'nitropack'
import StorageUtil from '~/utils/storage'// 存儲鍵名
const ACCESS_TOKEN_KEY = 'access_token'
const REFRESH_TOKEN_KEY = 'refresh_token'// 刷新狀態
let isRefreshing = false
let refreshSubscribers: ((token: string) => void)[] = []// 訂閱刷新事件
const subscribeTokenRefresh = (cb: (token: string) => void) => {refreshSubscribers.push(cb)
}// 發布新 Token
const onRefreshed = (token: string) => {refreshSubscribers.forEach(cb => cb(token))refreshSubscribers = []
}export const useApi = <T>(url: NitroFetchRequest, options?: any) => {const { $toast } = useNuxtApp()const accessToken = StorageUtil.get<string>(ACCESS_TOKEN_KEY)const refreshToken = StorageUtil.get<string>(REFRESH_TOKEN_KEY)// 刷新 Token 的函數const refreshTokens = async () => {try {// 防止并發刷新if (isRefreshing) {return new Promise<string>((resolve) => {subscribeTokenRefresh(resolve)})}isRefreshing = trueconst response = await $fetch<{ accessToken: string }>('/api/auth/refresh', {method: 'POST',body: { refreshToken }})const newAccessToken = response.accessTokenStorageUtil.set(ACCESS_TOKEN_KEY, newAccessToken)isRefreshing = falseonRefreshed(newAccessToken)return newAccessToken} catch (error) {console.error('刷新 Token 失敗:', error)isRefreshing = falserefreshSubscribers = []// 清除所有 token,跳轉到登錄StorageUtil.remove(ACCESS_TOKEN_KEY)StorageUtil.remove(REFRESH_TOKEN_KEY)navigateTo('/login')throw error}}return $fetch<T>(url, {...options,headers: {...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),...options?.headers,},async onResponseError({ response }) {const status = response?.status || 500const message = response?._data?.message || '請求失敗'// 401 錯誤處理:嘗試刷新 Tokenif (status === 401 && refreshToken) {try {const newAccessToken = await refreshTokens()// 使用新 Token 重試原請求const retryResponse = await $fetch<T>(url, {...options,headers: {...options?.headers,Authorization: `Bearer ${newAccessToken}`}})return retryResponse} catch (refreshError) {console.error('刷新 Token 后重試失敗:', refreshError)}}// 其他錯誤處理保持不變switch (status) {case 400:console.error('錯誤請求:', message)breakcase 401:console.error('未授權:', message)StorageUtil.remove(ACCESS_TOKEN_KEY)StorageUtil.remove(REFRESH_TOKEN_KEY)navigateTo('/login')breakcase 403:console.error('禁止訪問:', message)breakcase 404:console.error('未找到:', message)breakcase 500:console.error('服務器錯誤:', message)breakdefault:console.error('未知錯誤:', message)}$toast.error(message || '發生錯誤!', { position: 'top-center' })throw response}})
}

3. 用戶 API 封裝 (composables/useUserApi.ts)

import type { User, UserListResponse } from '~/types/user'
import StorageUtil from '@/utils/storage';
import { useApi } from '@/composables/useApi';export const useUserApi = () => {const router = useRouter()// 統一錯誤處理// const handleError = (error: any) => {//   const message = error.data?.message || error?.message || 'Request failed'//   const code = error.statusCode || 500//   // 顯示錯誤提示(可根據UI庫調整)//   console.error(message)//   // 401 未授權跳轉到登錄頁//   if (code === 401) {//     router.push('/login')//   }//   // 拋出格式化后的錯誤//   throw {//     code,//     message,//     data: error.data?.data || null//   }// }// 用戶注冊const register = async (userData: { username: string; password: string, role_id: number, disabled?: number }) => {try {const response = await $fetch('/api/register', {method: 'POST',body: userData,headers: {'Content-Type': 'application/json'}})// 顯示成功提示// console.log('Registration successful')return response} catch (error) {// return handleError(error)console.error('獲取數據失敗:', error)throw error}}// 用戶登錄const login = async (credentials: { username: string; password: string }) => {try {const response = await $fetch<any>('/api/login', {method: 'POST',body: credentials,headers: {'Content-Type': 'application/json'}})// 存儲兩種 Tokenif (response?.data?.accessToken && response?.data?.refreshToken) {StorageUtil.set('access_token', response.data.accessToken)StorageUtil.set('refresh_token', response.data.refreshToken)}return response} catch (error) {console.error('獲取數據失敗:', error)throw error}}const updateUser = async (userData: { password: string, role_id: number, disabled?: number }) => {try {const response = await useApi('/api/users', {method: 'PUT',body: userData,headers: {'Content-Type': 'application/json'}})// 顯示成功提示return response} catch (error) {// return handleError(error)console.error('獲取數據失敗:', error)throw error}}// 獲取用戶列表 (帶分頁)const getUsers = async (page: number = 1, size: number = 10) => {try {return await useApi<UserListResponse>('/api/users', {// method: 'POST',query: { page, size },headers: {'Content-Type': 'application/json'}})} catch (error) {console.error('獲取數據失敗:', error)throw error}}// 刪除用戶const deleteUser = async (id: string) => {try {return await useApi('/api/users', {method: 'DELETE',query: { id },headers: {'Content-Type': 'application/json'}})} catch (error) {console.error('獲取數據失敗:', error)throw error}}// 獲取單個用戶const getUser = async (id: string) => {try {return await useApi<User>(`/api/users/${id}`, {headers: {'Content-Type': 'application/json'}})} catch (error) {console.error('獲取數據失敗:', error)throw error}}// 切換用戶狀態const toggleUserStatus = async (id: string, disable: boolean) => {try {const response = await useApi(`/api/users/${id}/disable`, {method: 'PATCH',body: { disable: disable ? 1 : 0 },headers: {'Content-Type': 'application/json'}})// 顯示操作提示console.log(`User ${disable ? 'disabled' : 'enabled'} successfully`)return response} catch (error) {console.error('獲取數據失敗:', error)throw error}}return {register,login,getUsers,getUser,toggleUserStatus,updateUser,deleteUser}
}

4. 前端調用 (pages/login.vue)

<template><div class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4 sm:p-6"><div class="w-full max-w-md"><!-- 卡片容器 --><div class="bg-white rounded-2xl shadow-xl overflow-hidden"><!-- 頂部裝飾條 --><div class="h-2 bg-gradient-to-r from-blue-500 to-indigo-600"></div><!-- 內容區域 --><div class="p-8 sm:p-10"><!-- Logo和標題 --><div class="text-center mb-8"><div class="mx-auto h-12 w-12 rounded-full bg-blue-100 flex items-center justify-center mb-4"><svg class="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"d="M12 11c0 3.517-1.009 6.799-2.753 9.571m-3.44-2.04l.054-.09A13.916 13.916 0 008 11a4 4 0 118 0c0 1.017-.07 2.019-.203 3m-2.118 6.844A21.88 21.88 0 0015.171 17m3.839 1.132c.645-2.266.99-4.659.99-7.132A8 8 0 008 4.07M3 15.364c.64-1.319 1-2.8 1-4.364 0-1.457.39-2.823 1.07-4" /></svg></div><h2 class="text-2xl font-bold text-gray-800">歡迎回來</h2><p class="text-gray-500 mt-2">請登錄您的賬號</p></div><!-- 登錄表單 --><form class="space-y-5" @submit.prevent="handleLogin"><div><label for="username" class="block text-sm font-medium text-gray-700 mb-1">用戶名</label><input id="username" v-model="form.username" type="text" requiredclass="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 transition-all outline-none"placeholder="請輸入用戶名"></div><div><div class="flex justify-between items-center mb-1"><label for="password" class="block text-sm font-medium text-gray-700">密碼</label><a href="#" class="text-sm text-blue-600 hover:text-blue-500">忘記密碼?</a></div><input id="password" v-model="form.password" type="password" requiredclass="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 transition-all outline-none"placeholder="請輸入密碼"></div><div class="flex items-center"><input id="remember-me" type="checkbox"class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"><label for="remember-me" class="ml-2 block text-sm text-gray-700">記住我</label></div><button type="submit" :disabled="loading"class="w-full flex justify-center items-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-white bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all duration-300":class="{ 'opacity-70 cursor-not-allowed': loading }"><svg v-if="loading" class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg"fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor"d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>{{ loading ? '登錄中...' : '立即登錄' }}</button></form><!-- 注冊入口 --><div class="mt-6 text-center"><p class="text-sm text-gray-500">還沒有賬號?<a @click.prevent="navigateTo('/register')"class="font-medium text-blue-600 hover:text-blue-500 cursor-pointer ml-1">立即注冊</a></p></div><!-- 底部版權信息 --><div class="mt-8 text-center text-sm text-gray-500">? 2023 您的公司. 保留所有權利.</div></div></div>
</template><script setup lang="ts">
const { login } = useUserApi()
const { back } = useNavigation()
const router = useRouter()
// 方法
const navigateTo = (path: string) => {router.push(path)
}
const form = reactive({username: 'zrl',password: '123456'
})const loading = ref(false)const handleLogin = async () => {loading.value = truetry {await login(form)// navigateTo('/')back();} finally {loading.value = false}
}</script>

四、安全增強措施:從理論到實踐的細節

在實際項目中,我們還需要考慮以下安全細節:

  1. HttpOnly Cookie實戰配置
// 服務端設置Refresh Token Cookie
setCookie(event, 'refresh_token', refreshToken, {
httpOnly: true,
secure: true, // 僅HTTPS
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7 // 7天
})
  1. CSRF雙重驗證
// 在關鍵操作(如修改密碼)時要求二次驗證
const criticalAction = async (payload: {
csrfToken: string
password: string
}) => {
const storedCsrf = StorageUtil.get('csrf_token')
if (payload.csrfToken !== storedCsrf) {
throw new Error('非法請求')
}
// 執行敏感操作...
}
  1. IP綁定實現示例
// 生成Token時加入IP哈希
const generateToken = (user: User, ip: string) => {
const ipHash = createHash('sha256').update(ip).digest('hex')
return jwt.sign(
{
userId: user.id,
ipHash
},
SECRET_KEY
)
}

五、踩坑指南:真實項目經驗總結

在三個大型項目中實施這套方案后,我們總結了以下實戰經驗:

  1. 性能優化
  • 使用Redis緩存Refresh Token驗證結果,將數據庫查詢減少70%
  • 實現Token黑名單的自動清理機制
  1. 移動端適配
// 針對移動端延長Refresh Token有效期
const REFRESH_EXPIRES = isMobile() ? '30d' : '7d'
  1. 監控指標
  • 建立Token刷新成功率儀表盤
  • 設置異常刷新報警(如單用戶頻繁刷新)
  1. 降級方案
// 當刷新服務不可用時啟用降級模式
try {
await refreshToken()
} catch (error) {
if (isServerDown(error)) {
StorageUtil.set('fallback_mode', true)
extendTokenLocally() // 本地臨時延長Token有效期
}
}

建議開發團隊根據實際業務需求調整Token有效期和刷新策略,在安全性和用戶體驗之間找到最佳平衡點。關鍵是要建立完善的監控機制,確保能及時發現和處理異常情況。

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/diannao/90890.shtml
繁體地址,請注明出處:http://hk.pswp.cn/diannao/90890.shtml
英文地址,請注明出處:http://en.pswp.cn/diannao/90890.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

Linux——Redis

目錄 一、Redis概念 1.1 Redis定義 1.2 Redis的特點 1.3 Redis的用途 1.4 Redis與其他數據庫的對比 二、Redis數據庫 三、Redis五個基本類型 3.1 字符串 3.2 列表(list) ——可以有相同的值 3.3 集合(set) ——值不能重復 3.4 哈希(hash) ——類似于Map集合 3.5 有序…

【AI大模型】部署優化量化:INT8壓縮模型

INT8&#xff08;8位整數&#xff09;量化是AI大模型部署中最激進的壓縮技術&#xff0c;通過將模型權重和激活值從FP32降至INT8&#xff08;-128&#xff5e;127整數&#xff09;&#xff0c;實現4倍內存壓縮2-4倍推理加速&#xff0c;是邊緣計算和高并發服務的核心優化手段。…

LFU 緩存

題目鏈接 LFU 緩存 題目描述 注意點 1 < capacity < 10^40 < key < 10^50 < value < 10^9對緩存中的鍵執行 get 或 put 操作&#xff0c;使用計數器的值將會遞增當緩存達到其容量 capacity 時&#xff0c;則應該在插入新項之前&#xff0c;移除最不經常使…

檢查輸入有效性(指針是否為NULL)和檢查字符串長度是否為0

檢查輸入有效性&#xff08;指針是否為NULL&#xff09;和檢查字符串長度是否為0 這兩個檢查針對的是完全不同的邊界情況&#xff0c;都是必要的防御性編程措施&#xff1a; 1. 空指針檢查 if(!src) 目的&#xff1a;防止解引用空指針場景&#xff1a;當調用者傳入 NULL 時風險…

Apache POI 的 HSSFWorkbook、SXSSFWorkbook和XSSFWorkbook三者的區別

HSSFWorkbook 專用于處理Excel 97-2003&#xff08;.xls&#xff09;格式的二進制文件。基于純Java實現&#xff0c;所有數據存儲在內存中&#xff0c;適合小規模數據&#xff08;通常不超過萬行&#xff09;。內存占用較高&#xff0c;但功能完整&#xff0c;支持所有舊版Exce…

冷凍電鏡重構的GPU加速破局:從Relion到CryoSPARC的并行重構算法

點擊 “AladdinEdu&#xff0c;同學們用得起的【H卡】算力平臺”&#xff0c;H卡級別算力&#xff0c;按量計費&#xff0c;靈活彈性&#xff0c;頂級配置&#xff0c;學生專屬優惠。 一、冷凍電鏡重構的算力困局 隨著單粒子冷凍電鏡&#xff08;cryo-EM&#xff09;分辨率突破…

算法學習筆記:16.哈希算法 ——從原理到實戰,涵蓋 LeetCode 與考研 408 例題

在計算機科學中&#xff0c;哈希算法&#xff08;Hash Algorithm&#xff09;是一種將任意長度的輸入數據映射到固定長度輸出的技術&#xff0c;其輸出稱為哈希值&#xff08;Hash Value&#xff09;或散列值。哈希算法憑借高效的查找、插入和刪除性能&#xff0c;在數據存儲、…

16018.UE4+Airsim仿真環境搭建超級詳細

文章目錄 1 源碼下載2 下載安裝軟件2.1 安裝 UE4 軟件2.2 安裝visual studio 20223 編譯airsim源碼4 進入AirSim工程,打開工程5 UE4 工程創建5.1 下載免費場景 CityPark,并創建工程5.2 工程編譯5.2.1 將airsim 插件拷貝到 UE4工程路徑中5.2.2 修改工程配置文件5.2.3 創建c++類…

Python 實戰:構建 Git 自動化助手

在多項目協作、企業級工程管理或開源社區維護中&#xff0c;經常面臨需要同時管理數十甚至上百個 Git 倉庫的場景&#xff1a;多倉庫需要統一 pull 拉取更新定期向多個項目批量 commit 和 push自動備份 Git 項目批量拉取私有倉庫并管理密鑰為解決這類高頻、重復、機械性工作&am…

【PTA數據結構 | C語言版】出棧序列的合法性

本專欄持續輸出數據結構題目集&#xff0c;歡迎訂閱。 文章目錄題目代碼題目 給定一個最大容量為 m 的堆棧&#xff0c;將 n 個數字按 1, 2, 3, …, n 的順序入棧&#xff0c;允許按任何順序出棧&#xff0c;則哪些數字序列是不可能得到的&#xff1f;例如給定 m5、n7&#xf…

【LangGraph】create_react_agent 方法詳細解釋

create_react_agent 方法詳細解釋 create_react_agent 方法是一個在 LangGraph 中創建 React 代理的核心函數,接下來我們將一起探討這個函數的作用、參數、返回值以及工作原理。 @_convert_modifier_to_prompt def create_react_agent(model: Union[str, LanguageModelLike]…

【時間之外】塵封的智能套件復活記

目錄 塵封的獎品 初次觸網的挫敗 客服只會誘導消費 意外發現的生機 真相與反思 塵封的獎品 五年前那個蟬鳴陣陣的夏日&#xff0c;我抱著創新比賽特等獎的獎品禮盒走下領獎臺時&#xff0c;絕對想不到這份榮譽會衍生出如此曲折的故事。禮盒里靜靜躺著的智能家居套裝&…

從零開始學前端html篇1

1基本結構<!DOCTYPE html> <html><head><title>this is a good website</title></head><body><h1>hello!</h1></body> </html>運行效果如下&#xff08;編輯器提示waings:"缺少所需的 lang 特性"…

Redis Cluster 手動部署(小白的“升級打怪”成長之路)

目錄 一、環境規劃 二、基礎環境 1、創建配置目錄 2、生成配置文件 3、修改監聽端口 4、修改數據目錄 5、修改日志目錄 6、修改PID文件目錄 7、修改保護模式 8、修改進程運行模式 9、修改監聽地址 10、生成集群配置 11、啟動服務 三、構建集群 1、將其他節點加入…

【Java入門到精通】(三)Java基礎語法(下)

一、面向對象&#xff08;類和對象&#xff09;1.1 萬事萬物皆對象類&#xff1a;對對象向上抽取出像的部分、公共的部分以此形成類&#xff0c;類就相當于一個模板。對象&#xff1a;模板下具體的產物可以理解為具體對象&#xff0c;對象就是一個一個具體的實例&#xff0c;就…

Java文件傳輸要點

Java文件傳輸要點 一、前端 <form action"/upload" method"post" enctype"multipart/form-data"> <!--<form action"/upload" method"post">-->姓名: <input type"text" name"username…

Spring Boot 中使用 Lombok 進行依賴注入的示例

Spring Boot 中使用 Lombok 進行依賴注入的示例 下面我將展示 Spring Boot 中使用 Lombok 進行依賴注入的不同方式&#xff0c;包括構造器注入、屬性注入和 setter 方法注入&#xff0c;以及相應的測試用例。 1. 構造器注入&#xff08;推薦方式&#xff09; import lombok.Req…

vue3+vit+vue-router路由,側邊欄菜單,面包屑導航設置層級結構

文章目錄注意效果圖目錄結構代碼vite.config.ts需要配置路徑別名符號main.tsApp.vueBreadcrumb.vue面包屑組件menus.ts// src/router/index.ts其他文件注意 目錄結構僅供參考DefaultLayout.vue 沒有用到&#xff0c;我直接寫在APP文件中vux-store我也沒有用到&#xff0c;單獨…

使用Selenium自動化獲取抖音創作者平臺視頻數據

前言 在當今短視頻盛行的時代&#xff0c;抖音作為國內領先的短視頻平臺&#xff0c;吸引了大量內容創作者。對于創作者而言&#xff0c;了解自己發布的視頻表現&#xff08;如播放量、發布時間等&#xff09;至關重要。本文將介紹如何使用Python的Selenium庫來自動化獲取抖音…

SpringCloud之Eureka

SpringCloud之Eureka 推薦參考&#xff1a;https://www.springcloud.cc/spring-cloud-dalston.html#_service_discovery_eureka_clients 1. 什么是Eureka Eureka 用于簡化分布式系統的服務治理&#xff0c;基于REST的服務&#xff0c;用于服務的注冊與發現。通過注冊發現、客戶…