网站首页 > 精选文章 正文
前言
在现代 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工作流程
- 首次认证:
- 用户提交凭证 → 获取accessToken(1h) + refreshToken(7d)
- 正常请求:
// 典型请求头
headers: {
'Authorization': `Bearer ${accessToken}`,
'X-Request-ID': uuidv4() // 防止重放攻击
}
- Token刷新:
- 用户使用凭证登录,获取双 Token
- 每次请求携带 Access Token
- Access Token 过期时,自动使用 Refresh Token 获取新 Token
- 刷新成功后继续原请求,用户无感知
- 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
} | null
if (!decoded || !decoded.userId || !decoded.user_id) {
throw new Error('无效的令牌格式')
}
let expiresAt: Date
if (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 UPDATE
token = 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, '账号已禁用')
}
// 生成两种 Token
const 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 Token
try {
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 = 0
LIMIT 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 } = body
if (!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' }
)
// 存储新的 refreshToken
const 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.path
const 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 routes
if (event.path?.startsWith('/api/auth')) {
return
}
// 2. 检查是否包含token
const authHeader = getHeader(event, 'Authorization')
const token = authHeader?.startsWith('Bearer ')
? authHeader.split(' ')[1]
: authHeader || getCookie(event, 'auth_token')
// console.log('Token:', token) // Debug
if (!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 JwtPayloadWithRole
event.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: number
insertId: number
warningStatus: number
message?: string
}
interface RowDataPacket {
[column: string]: any
[column: number]: any
}
interface ResultSetHeader {
fieldCount: number
affectedRows: number
insertId: number
info?: string
serverStatus?: number
warningStatus?: number
}
}
types/middleware/user.ts
export interface User {
user_id: string
username: string
role: string
disable?: number
create_time?: string
}
export interface UserListResponse {
data: {
list: User[]
}
pagination: {
currentPage: number
perPage: number
total: number
totalPages: number
}
}
export interface LoginResponse {
code: number
token: string
user: {
user_id: string
username: string
role: 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: ResponseCode
message: string
data?: T
timestamp: number
success: 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 = true
const response = await $fetch<{ accessToken: string }>('/api/auth/refresh', {
method: 'POST',
body: { refreshToken }
})
const newAccessToken = response.accessToken
StorageUtil.set(ACCESS_TOKEN_KEY, newAccessToken)
isRefreshing = false
onRefreshed(newAccessToken)
return newAccessToken
} catch (error) {
console.error('刷新 Token 失败:', error)
isRefreshing = false
refreshSubscribers = []
// 清除所有 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 || 500
const message = response?._data?.message || '请求失败'
// 401 错误处理:尝试刷新 Token
if (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)
break
case 401:
console.error('未授权:', message)
StorageUtil.remove(ACCESS_TOKEN_KEY)
StorageUtil.remove(REFRESH_TOKEN_KEY)
navigateTo('/login')
break
case 403:
console.error('禁止访问:', message)
break
case 404:
console.error('未找到:', message)
break
case 500:
console.error('服务器错误:', message)
break
default:
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'
}
})
// 存储两种 Token
if (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" required
class="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" required
class="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">
(c) 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 = true
try {
await login(form)
// navigateTo('/')
back();
} finally {
loading.value = false
}
}
</script>
四、安全增强措施:从理论到实践的细节
在实际项目中,我们还需要考虑以下安全细节:
- HttpOnly Cookie实战配置:
// 服务端设置Refresh Token Cookie
setCookie(event, 'refresh_token', refreshToken, {
httpOnly: true,
secure: true, // 仅HTTPS
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7 // 7天
})
- CSRF双重验证:
// 在关键操作(如修改密码)时要求二次验证
const criticalAction = async (payload: {
csrfToken: string
password: string
}) => {
const storedCsrf = StorageUtil.get('csrf_token')
if (payload.csrfToken !== storedCsrf) {
throw new Error('非法请求')
}
// 执行敏感操作...
}
- 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
)
}
五、踩坑指南:真实项目经验总结
在三个大型项目中实施这套方案后,我们总结了以下实战经验:
- 性能优化:
- 使用Redis缓存Refresh Token验证结果,将数据库查询减少70%
- 实现Token黑名单的自动清理机制
- 移动端适配:
// 针对移动端延长Refresh Token有效期
const REFRESH_EXPIRES = isMobile() ? '30d' : '7d'
- 监控指标:
- 建立Token刷新成功率仪表盘
- 设置异常刷新报警(如单用户频繁刷新)
- 降级方案:
// 当刷新服务不可用时启用降级模式
try {
await refreshToken()
} catch (error) {
if (isServerDown(error)) {
StorageUtil.set('fallback_mode', true)
extendTokenLocally() // 本地临时延长Token有效期
}
}
建议开发团队根据实际业务需求调整Token有效期和刷新策略,在安全性和用户体验之间找到最佳平衡点。关键是要建立完善的监控机制,确保能及时发现和处理异常情况。
- 上一篇: 斯皮尔伯格:流媒体电影没资格拿奥斯卡?
- 下一篇: element-ui实现动态表头的表格问题汇总
猜你喜欢
- 2025-07-21 Vue 前端开发——导入Excel/Csv(vue前端导入excel文件)
- 2025-07-21 element-ui实现动态表头的表格问题汇总
- 2025-07-21 斯皮尔伯格:流媒体电影没资格拿奥斯卡?
- 2025-07-21 Vue3 “微商城”前台开发文档(vue前端开发工具)
- 2025-07-21 Ant Design of Vue 组件 a-table 如何横向排列
- 2025-07-21 超简 Vue3+Ts 可视化拖拽设计器DreamDesign
- 2025-07-21 基于 Vue.js 磁片栅格布局组件VueGridLayout
- 2025-07-21 vue中的select下拉框多选以及多选数据回显
- 2025-07-21 Vue脚手架使用Element UI(vue脚手架使用视频教程与步骤)
- 2025-07-21 Vue3 + Element Plus 实现跨页选择的 el-table 表格功能
- 最近发表
-
- Vue 前端开发——导入Excel/Csv(vue前端导入excel文件)
- element-ui实现动态表头的表格问题汇总
- 告别频繁登录!Nuxt3 + TS + Vue3实战:双Token无感刷新方案全解析
- 斯皮尔伯格:流媒体电影没资格拿奥斯卡?
- Vue3 “微商城”前台开发文档(vue前端开发工具)
- Ant Design of Vue 组件 a-table 如何横向排列
- 超简 Vue3+Ts 可视化拖拽设计器DreamDesign
- 基于 Vue.js 磁片栅格布局组件VueGridLayout
- vue中的select下拉框多选以及多选数据回显
- Vue脚手架使用Element UI(vue脚手架使用视频教程与步骤)
- 标签列表
-
- 向日葵无法连接服务器 (32)
- git.exe (33)
- vscode更新 (34)
- dev c (33)
- git ignore命令 (32)
- gitlab提交代码步骤 (37)
- java update (36)
- vue debug (34)
- vue blur (32)
- vscode导入vue项目 (33)
- vue chart (32)
- vue cms (32)
- 大雅数据库 (34)
- 技术迭代 (37)
- 同一局域网 (33)
- github拒绝连接 (33)
- vscode php插件 (32)
- vue注释快捷键 (32)
- linux ssr (33)
- 微端服务器 (35)
- 导航猫 (32)
- 获取当前时间年月日 (33)
- stp软件 (33)
- http下载文件 (33)
- linux bt下载 (33)