前言

在现代 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;用于服务的注册与发现。通过注册发现、客户…