缘起:那个被偷走的夜晚
记得那是一个深夜,我正在调试我的Next.js全栈项目。突然,收到用户反馈说账户异常登录。调查后发现,原来是单Token方案出了问题——用户的Token被恶意脚本窃取,导致账户被盗。
那一刻我意识到,传统的单Token方案就像把家门钥匙放在门口的垫子下面,虽然方便,但极不安全。于是,我开始了双Token的探索之旅。
第一章:为什么单Token不够安全?
1.1 单Token的困境
想象一下,你有一个长期有效的令牌(Token)存储在localStorage中。这就像拥有一张永不过期的门禁卡,一旦丢失,别人就可以随意进出你的家。
单Token的风险:
- 长期有效:一旦泄露,攻击者可以长期使用
- 存储风险:localStorage易受XSS攻击
- 无法撤销:除非修改密钥,否则无法单独撤销某个Token
// 传统的单Token方案
const token = localStorage.getItem('token');
// 危险!容易被XSS攻击窃取
1.2 真实世界的比喻
让我们用现实生活来理解这个问题:
- 单Token = 长期有效的门禁卡(丢了就完蛋)
- 双Token = 短期门禁卡 + 长期续期凭证
- 门禁卡15分钟有效(accessToken)
- 续期凭证7天有效(refreshToken)
- 即使门禁卡被偷,很快也会失效
第二章:双Token的神秘面纱
2.1 什么是双Token?
双Token方案由两个令牌组成:
- accessToken:短期访问令牌,用于API身份验证(15分钟-1小时)
- refreshToken:长期刷新令牌,用于获取新的accessToken(7天)
// 双Token的创建
const createTokens = async (userId: number) => {
const accessToken = await new SignJWT({ userId })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('15m') // 短期有效
.sign(secretKey);
const refreshToken = await new SignJWT({ userId })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d') // 长期有效
.sign(secretKey);
return { accessToken, refreshToken };
};
2.2 双Token的工作流程
让我用生活中的场景来解释这个流程:
情景:你去健身房锻炼
accessToken = 储物柜钥匙(锻炼期间有效)
refreshToken = 会员卡(长期有效)
- 用会员卡(refreshToken)在前台登记
- 获得储物柜钥匙(accessToken)
- 钥匙15分钟后自动锁柜(过期)
- 用会员卡再次获取新钥匙(无感刷新)
- 会员卡过期后需要重新办卡(重新登录)
第三章:Next.js中的双Token实战
3.1 项目架构概览
我们的Next.js全栈项目包含:
- 用户认证系统(Users & Posts)
- JWT双Token鉴权
- 虚拟列表优化
- 大文件上传
- AI工程化功能
今天,我们重点深入双Token的实现细节。
3.2 数据库设计:Prisma Schema
// 这是你的数据库设计图
model User {
id Int @id @default(autoincrement())
email String @unique
password String
refreshToken String? // 存储refreshToken
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
posts Post[]
@@map("users")
}
Prisma Schema就像是建筑的蓝图,它定义了数据的结构和关系。比传统的Navicat更好的地方在于,它可以通过Git记录数据库的变更历史,让团队协作更加顺畅。
3.3 注册:安全的起点
import { emailRegex, passwordRegex } from '@/lib/regexp';
import bcrypt from 'bcryptjs';
export async function POST(req: NextRequest) {
try {
const { email, password } = await req.json();
// 正则验证:前端后端都要会的必备技能
if (!email || !emailRegex.test(email)) {
return NextResponse.json({ error: '邮箱格式错误' }, { status: 400 });
}
if (!password || !passwordRegex.test(password)) {
return NextResponse.json({
error: '密码需要6-18位,包含字母、数字和特殊字符'
}, { status: 400 });
}
// 密码加密:单向加密的魔法
const hashedPassword = await bcrypt.hash(password, 10);
await prisma.user.create({
data: { email, password: hashedPassword }
});
return NextResponse.json({ message: '注册成功' }, { status: 201 });
} catch (error) {
return NextResponse.json({ error: '注册失败' }, { status: 500 });
} finally {
// 重要!释放数据库连接
await prisma.$disconnect();
}
}
安全要点解析:
- 正则验证:
/^.+@.+\..+$/确保邮箱格式正确 - 密码强度:
/^(?!^\d+$)^[a-zA-Z0-9!@#$%^&*]{6,18}$/防止简单密码 - bcrypt加密:单向哈希,即使数据库泄露也不会暴露明文密码
3.4 登录:双Token的诞生
export async function POST(req: NextRequest) {
try {
const { email, password } = await req.json();
// 验证用户是否存在
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
}
// 密码验证:比较哈希值
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
return NextResponse.json({ error: '密码错误' }, { status: 401 });
}
// 创建双Token
const { accessToken, refreshToken } = await createTokens(user.id);
// 存储refreshToken到数据库
await prisma.user.update({
where: { id: user.id },
data: { refreshToken }
});
// 设置安全的Cookie
setAuthCookies(accessToken, refreshToken);
return NextResponse.json({ message: '登录成功' });
} catch (error) {
return NextResponse.json({ error: '登录失败' }, { status: 500 });
}
}
3.5 Token创建与验证的魔法
import { SignJWT, jwtVerify } from 'jose';
const getJwtSecretKey = () => {
const secret = process.env.JWT_SECRET_KEY;
if (!secret) throw new Error('JWT_SECRET_KEY is not defined');
return new TextEncoder().encode(secret); // 二进制secret更安全
};
export const createTokens = async (userId: number) => {
const accessToken = await new SignJWT({ userId })
.setProtectedHeader({ alg: 'HS256' }) // 使用HS256算法
.setIssuedAt() // 颁发时间
.setExpirationTime('15m') // 15分钟过期
.sign(getJwtSecretKey());
const refreshToken = await new SignJWT({ userId })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d') // 7天过期
.sign(getJwtSecretKey());
return { accessToken, refreshToken };
};
JWT的三大构成:
- 头部:声明类型和签名算法
{ "alg": "HS256", "typ": "JWT" } - 载荷:包含声明信息
{ "userId": 123, "iat": 1620000000, "exp": 1620000900 } - 签名:确保Token不被篡改
3.6 安全的Cookie设置
export const setAuthCookies = async (accessToken: string, refreshToken: string) => {
const cookieStore = await cookies();
cookieStore.set('accessToken', accessToken, {
httpOnly: true, // 防止XSS攻击,JavaScript无法读取
sameSite: 'strict', // 防止CSRF攻击
path: '/',
maxAge: 15 * 60 // 15分钟
});
cookieStore.set('refreshToken', refreshToken, {
httpOnly: true,
sameSite: 'strict',
path: '/',
maxAge: 7 * 24 * 60 * 60 // 7天
});
};
安全特性解析:
- httpOnly:阻止JavaScript访问,有效防御XSS攻击
- sameSite=strict:阻止跨站请求,有效防御CSRF攻击
- 合理的过期时间:平衡安全性和用户体验
第四章:Middleware:无感刷新的守护神
4.1 Middleware的概念
Middleware就像是公司的前台接待,负责:
- 检查每个访客的证件(Token验证)
- 决定是否放行到相应部门(路由保护)
- 处理证件更新(Token刷新)
import { NextRequest, NextResponse } from 'next/server';
import { verifyToken } from './lib/jwt';
// 需要登录保护的路径
const protectedPath = ['/dashboard', '/profile'];
export async function middleware(req: NextRequest) {
const pathname = req.nextUrl.pathname;
// 非保护路径直接放行
if (!protectedPath.some(path => pathname.startsWith(path))) {
return NextResponse.next();
}
// 检查证件(Token)
const accessToken = req.cookies.get('accessToken')?.value;
const refreshToken = req.cookies.get('refreshToken')?.value;
// 没有证件?请去登录!
if (!accessToken && !refreshToken) {
return NextResponse.redirect(new URL('/login', req.url));
}
// accessToken有效,添加用户信息到请求头
if (accessToken) {
const accessPayload = await verifyToken(accessToken);
if (accessPayload) {
const requestHeaders = new Headers(req.headers);
requestHeaders.set('x-user-id', accessPayload.userId as string);
return NextResponse.next({ request: { headers: requestHeaders } });
}
}
// accessToken过期,但refreshToken有效?无感刷新!
if (refreshToken) {
const refreshPayload = await verifyToken(refreshToken);
if (refreshPayload) {
const refreshUrl = new URL('/api/auth/refresh', req.url);
refreshUrl.searchParams.set('redirect', req.url);
return NextResponse.redirect(refreshUrl);
}
}
// 所有Token都无效,重新登录
return NextResponse.redirect(new URL('/login', req.url));
}
4.2 无感刷新:优雅的续期机制
export async function GET(req: NextRequest) {
try {
const refreshToken = req.cookies.get('refreshToken')?.value;
const redirectUrl = req.nextUrl.searchParams.get('redirect') || '/dashboard';
if (!refreshToken) {
return NextResponse.redirect(new URL('/login', req.url));
}
// 验证refreshToken
const refreshPayload = await verifyToken(refreshToken);
if (!refreshPayload || !refreshPayload.userId) {
return NextResponse.redirect(new URL('/login', req.url));
}
const userId = refreshPayload.userId as number;
// 数据库二次验证:确保refreshToken未被撤销
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user || user?.refreshToken !== refreshToken) {
return NextResponse.redirect(new URL('/login', req.url));
}
// 生成新的双Token
const { accessToken: newAccessToken, refreshToken: newRefreshToken } =
await createTokens(userId);
// 更新数据库中的refreshToken
await prisma.user.update({
where: { id: userId },
data: { refreshToken: newRefreshToken }
});
// 设置新的Cookie
const response = NextResponse.redirect(new URL(redirectUrl, req.url));
response.cookies.set('accessToken', newAccessToken, {
httpOnly: true, maxAge: 15 * 60, sameSite: 'strict', path: '/'
});
response.cookies.set('refreshToken', newRefreshToken, {
httpOnly: true, maxAge: 7 * 24 * 60 * 60, sameSite: 'strict', path: '/'
});
return response;
} catch (error) {
return NextResponse.redirect(new URL('/login', req.url));
}
}
第五章:前端页面的完美配合
5.1 登录页面实现
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const router = useRouter()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (res.ok) {
router.push('/dashboard') // 登录成功跳转
} else {
const data = await res.json()
setError(data.error || '登录失败')
}
} catch (err) {
setError('网络错误')
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
登录您的账户
</h2>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && <div className="text-red-500 text-sm">{error}</div>}
<div>
<label htmlFor="email" className="sr-only">邮箱</label>
<input
id="email"
name="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="邮箱地址"
/>
</div>
<div>
<label htmlFor="password" className="sr-only">密码</label>
<input
id="password"
name="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="密码"
/>
</div>
<div>
<button
type="submit"
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
登录
</button>
</div>
</form>
</div>
</div>
)
}
5.2 受保护的Dashboard页面
const Dashboard = () => {
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">欢迎来到控制台</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="bg-white p-6 rounded-lg shadow-md">
<h3 className="text-lg font-semibold mb-2">用户统计</h3>
<p className="text-gray-600">查看您的账户数据和活动</p>
</div>
<div className="bg-white p-6 rounded-lg shadow-md">
<h3 className="text-lg font-semibold mb-2">内容管理</h3>
<p className="text-gray-600">管理您的帖子和文章</p>
</div>
<div className="bg-white p-6 rounded-lg shadow-md">
<h3 className="text-lg font-semibold mb-2">安全设置</h3>
<p className="text-gray-600">保护您的账户安全</p>
</div>
</div>
</div>
);
}
export default Dashboard;
第六章:安全深度解析
6.1 状态码的艺术
正确的HTTP状态码不仅是规范,更是安全的一部分:
// 200 OK - 请求成功
return NextResponse.json({ message: '操作成功' });
// 201 Created - 资源创建成功
return NextResponse.json({ message: '注册成功' }, { status: 201 });
// 400 Bad Request - 客户端错误
return NextResponse.json({ error: '请求参数错误' }, { status: 400 });
// 401 Unauthorized - 未授权
return NextResponse.json({ error: '身份验证失败' }, { status: 401 });
// 409 Conflict - 资源冲突
return NextResponse.json({ error: '邮箱已存在' }, { status: 409 });
// 500 Internal Server Error - 服务器错误
return NextResponse.json({ error: '服务器内部错误' }, { status: 500 });
6.2 正则表达式的威力
正则表达式是前后端开发者的必备技能:
// 邮箱验证:确保基本的邮箱格式
export const emailRegex = /^.+@.+\..+$/;
// 密码强度验证:
// - 不能全是数字 (?!^\d+$)
// - 6-18位长度 {6,18}
// - 允许字母、数字和特殊字符 [a-zA-Z0-9!@#$%^&*]
export const passwordRegex = /^(?!^\d+$)^[a-zA-Z0-9!@#$%^&*]{6,18}$/;
6.3 数据库安全实践
// 永远使用try-catch处理数据库操作
try {
const user = await prisma.user.findUnique({ where: { email } });
// 业务逻辑...
} catch (error) {
console.error('数据库操作失败:', error);
return NextResponse.json({ error: '操作失败' }, { status: 500 });
} finally {
// 重要!释放数据库连接
await prisma.$disconnect();
}
第七章:性能与用户体验的平衡
7.1 Token过期时间的权衡
accessToken过期时间:
- 太短:频繁刷新影响体验
- 太长:安全风险增加
- 推荐:15分钟-1小时
refreshToken过期时间:
- 推荐:7天-30天
- 考虑因素:应用敏感程度、用户使用习惯
第八章:扩展与进阶
8.1 与AI工程化结合
在我们的Next.js全栈项目中,双Token还可以与AI功能完美结合:
// 保护AI API端点
export async function POST(req: NextRequest) {
const accessToken = req.cookies.get('accessToken')?.value;
if (!accessToken) {
return NextResponse.json({ error: '未授权' }, { status: 401 });
}
const payload = await verifyToken(accessToken);
if (!payload) {
return NextResponse.json({ error: 'Token无效' }, { status: 401 });
}
// 处理AI请求...
const aiResponse = await handleAIRequest(req, payload.userId);
return NextResponse.json(aiResponse);
}
8.2 大文件上传的认证
// 为文件上传生成预签名URL
export async function POST(req: NextRequest) {
const accessToken = req.cookies.get('accessToken')?.value;
if (!accessToken) {
return NextResponse.json({ error: '未授权' }, { status: 401 });
}
const { fileName, fileType } = await req.json();
// 生成带Token的预签名URL
const uploadUrl = await generatePresignedUrl(fileName, fileType, accessToken);
return NextResponse.json({ uploadUrl });
}
第九章:总结与最佳实践
经过这段双Token的奇幻漂流,我们学到了:
9.1 核心优势
- 安全性:短期accessToken减少泄露风险
- 用户体验:无感刷新避免频繁登录
- 可控性:可单独撤销refreshToken
- 扩展性:支持多设备管理和权限控制
9.2 最佳实践清单
✅ 一定要做的:
- 使用httpOnly和sameSite Cookie
- 合理的Token过期时间
- 数据库存储refreshToken用于验证
- 完整的错误处理和状态码
- 正则验证输入数据
❌ 一定要避免的:
- 在localStorage存储Token
- 过长的accessToken有效期
- 忽略数据库连接的释放
- 弱密码策略
- 不验证refreshToken的合法性
9.3 面向未来
随着技术的发展,双Token方案也在进化:
- RTM(Refresh Token Rotation):每次刷新都生成新的refreshToken
- BFF模式:Backend for Frontend,进一步隔离前端与认证逻辑
- 无密码认证:结合生物识别和Passkeys
结语
双Token方案就像为我们的应用穿上了一件既安全又舒适的外衣。它不再是冰冷的技术实现,而是用户体验与安全防护的完美平衡。
正如我们在Next.js全栈项目中实践的那样,好的技术方案应该像优秀的用户体验一样——让用户感受不到它的存在,却又无处不在保护着他们。