双Token奇遇记:从前端安全到无感刷新的奇幻漂流

163 阅读5分钟

缘起:那个被偷走的夜晚

记得那是一个深夜,我正在调试我的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方案由两个令牌组成:

  1. accessToken:短期访问令牌,用于API身份验证(15分钟-1小时)
  2. 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 = 会员卡(长期有效)

  1. 用会员卡(refreshToken)在前台登记
  2. 获得储物柜钥匙(accessToken)
  3. 钥匙15分钟后自动锁柜(过期)
  4. 用会员卡再次获取新钥匙(无感刷新)
  5. 会员卡过期后需要重新办卡(重新登录)

第三章: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();
  }
}

安全要点解析:

  1. 正则验证/^.+@.+\..+$/ 确保邮箱格式正确
  2. 密码强度/^(?!^\d+$)^[a-zA-Z0-9!@#$%^&*]{6,18}$/ 防止简单密码
  3. 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的三大构成:

  1. 头部:声明类型和签名算法 { "alg": "HS256", "typ": "JWT" }
  2. 载荷:包含声明信息 { "userId": 123, "iat": 1620000000, "exp": 1620000900 }
  3. 签名:确保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 核心优势

  1. 安全性:短期accessToken减少泄露风险
  2. 用户体验:无感刷新避免频繁登录
  3. 可控性:可单独撤销refreshToken
  4. 扩展性:支持多设备管理和权限控制

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全栈项目中实践的那样,好的技术方案应该像优秀的用户体验一样——让用户感受不到它的存在,却又无处不在保护着他们。