双Token机制

今天我要分享一个让我眼前一亮的技术方案------双Token认证机制。作为一个前端初学者,我一直对用户认证和安全问题感到头疼,直到学会了这个方案,才发现原来安全登录可以这么优雅地实现!

为什么传统的单Token方案不够安全?

在我们开始之前,先来看看我之前是怎么做用户登录的:用户输入账号密码,后端验证通过后返回一个Token,我把这个Token存在localStorage里,每次请求API时带上它。这种方法简单直接,但有个致命问题------如果这个Token被黑客窃取了,他们就可以冒充用户为所欲为!

想象一下,这就像你把家里的钥匙交给别人保管,结果对方偷偷配了一把,随时可以进入你家。传统的单Token方案就存在这样的风险。

双Token方案是如何解决这个问题的?

双Token机制用了两个Token:accessToken和refreshToken。accessToken就像小区的门禁卡,有效期很短(比如15分钟);refreshToken就像房产证明,有效期很长(比如7天),但不会随身携带。

typescript 复制代码
export const createTokens = async (userId: number) => {
  const accessToken = await new SignJWT({ userId })
  // 创建JWT 载荷
  // 设置头部,指定使用HS256算法签名
    .setProtectedHeader({ alg: 'HS256' })
    // 颁发的时间 当前时间
    .setIssuedAt()
    // 设置过期时间
    .setExpirationTime('15m')
    // 使用secret签名
    .sign(getJwtSecretKey())

  const refreshToken = await new SignJWT({ userId })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('7d')
    .sign(getJwtSecretKey())

  return {
    accessToken,
    refreshToken
  }
}

这样做的好处是:即使accessToken被泄露了,黑客也只能在很短的时间内使用它,就像小偷拿到了你的门禁卡,但15分钟后就会自动失效!这里我们借助的是 jose来完成生成Token的任务

项目结构和技术选型

我用了Next.js框架,因为它同时支持前后端开发,很适合做这种全栈项目。数据库选择了MySQL,用Prisma这个ORM工具来操作数据库,这让数据库操作变得像操作JavaScript对象一样简单!

先来看看我的数据库设计,在schema.prisma文件中:

kotlin 复制代码
model User {
  id           Int      @id @default(autoincrement())
  email        String   @unique
  password     String
  refreshToken String?
  createdAt    DateTime @default(now())
  updatedAt    DateTime @updatedAt
  posts        Post[]

  @@map("users")
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String   @db.VarChar(255)
  content   String   @db.Text
  published Boolean  @default(false) // 草稿
  author    User     @relation(fields: [authorId], references: [id])
  authorId  Int // 外键
  createAT  DateTime @default(now())
  updateAT  DateTime @updatedAt

  @@map("posts")
}

npx prisma migrate dev --name init 初始化数据库表结构,可以可以看到,我们的表格是背成功建立的 这里定义了用户模型,每个用户有唯一的邮箱、密码、可选的refreshToken字段,以及创建和更新时间。这样我们就不需要去写建表语句了,主要还是可以和git结合,有版本控制的功能

环境变量配置

.env文件中,我配置了数据库连接和JWT密钥:

ini 复制代码
DATABASE_URL=mysql://root:root@localhost:3306/demo
JWT_SECRET_KEY=dwk20031119@

JWT密钥非常重要,它是Token签名的基础,必须足够复杂且妥善保管,就像你家的保险柜密码一样!

核心工具函数

我在lib目录下创建了几个工具模块,首先是db.ts,这里初始化了Prisma客户端:

javascript 复制代码
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();

整个应用都通过这个prisma实例与数据库交互,避免了重复创建连接。

然后是jwt.ts,这里包含了Token相关的所有核心功能:

javascript 复制代码
const getJwtSecretKey = (() => {
  const secret = process.env.JWT_SECRET_KEY;
  if (!secret) throw new Error("JWT_SECRET_KEY is not set");
  return new TextEncoder().encode(secret);
})

这个函数获取环境变量中的JWT密钥并编码成特定格式,后面生成和验证Token时都会用到它。

php 复制代码
export const setAuthCookies = async (accessToken: string, refreshToken: string) => {
  const cookieStore = await cookies();
  console.log('//////////////////')
  cookieStore.set('access_token', accessToken, {
    httpOnly: true, // 不能用javascript操作cookie
    maxAge: 60 * 15,
    sameSite: 'strict',
    path: '/'
  });
  cookieStore.set('refresh_token', accessToken, {
    httpOnly: true, // 不能用javascript操作cookie
    maxAge: 60 * 60 * 24 * 7, // 7天
    sameSite: 'strict',
    path: '/'
  })
}

注意看这里的httpOnly: true,这表示JavaScript无法读取这些cookie,有效防止XSS攻击窃取Token。sameSite: 'strict'可以防止CSRF攻击。这两个设置对安全性至关重要!

正则表达式验证

regex.ts中,我定义了邮箱和密码的验证规则:

javascript 复制代码
export const emailRegex = /^.+@.+..+$/ // RegExp
export const passwordRegex = /^(?!^\d+$)^[a-zA-Z0-9!@#$%^&*]{6,18}$/

邮箱正则比较简单,只验证基本格式;密码正则要求6-18位,不能全是数字,可以包含字母、数字和特殊字符。这种验证在前端和后端都要做,前端为了用户体验,后端为了安全。

可以看到不符合要求,是不被允许注册的

用户注册流程

现在让我们看看用户注册的完整流程。当用户在注册页面填写信息并提交时,请求会发送到app/api/auth/register/route.ts

php 复制代码
export async function POST(request: NextRequest) {
  try {
    const { email, password } = await request.json();
    
    // 验证邮箱格式
    if (!email || !emailRegex.test(email)) {
      return NextResponse.json({ error: `邮箱格式无效` }, { status: 400 })
    }
    
    // 验证密码格式
    if (!password || !passwordRegex.test(password)) {
      return NextResponse.json({ 
        error: `密码需要6-8位,包含字母、数字、特殊字符` 
      }, { status: 400 })
    }
    
    // 检查用户是否已存在
    const existingUser = await prisma.user.findUnique({
      where: { email }
    })
    
    if (existingUser) {
      return NextResponse.json({ error: `用户已存在` }, { status: 409 })
    }
    
    // 加密密码
    const hashedPassword = await bcrypt.hash(password, 10);
    
    // 创建用户
    const user = await prisma.user.create({
      data: { email, password: hashedPassword }
    })
    
    return NextResponse.json({ message: '注册成功' }, { status: 201 });
  } catch (err) {
    console.log(err);
    return NextResponse.json({ error: '注册失败' }, { status: 500 })
  } finally {
    await prisma.$disconnect() // 释放数据库连接
  }
}

这个过程中有几个关键点:首先是对输入格式的验证,既要在前端做也要在后端做。前端验证是为了用户体验,后端验证是为了安全,因为恶意用户可能绕过前端直接调用API。

其次是密码加密,我们使用bcryptjs对密码进行哈希处理:

ini 复制代码
const hashedPassword = await bcrypt.hash(password, 10);

我们存储在数据库中的密码都是经过单向加密的

这行代码的重要性怎么强调都不为过!绝对不能明文存储密码,否则一旦数据库泄露,所有用户的密码就都暴露了。bcrypt是一种单向哈希算法,无法反向解密,而且每次哈希的结果都不同,即使相同的密码也会得到不同的哈希值。

最后是异常处理和资源清理,使用try-catch-finally结构确保无论发生什么情况都会释放数据库连接,这是编写可靠后端代码的基本要求。

这里我们使用apifox测试,注册成功

用户登录与Token发放

用户登录的流程在app/api/auth/login/route.ts中处理:

php 复制代码
export async function POST(request: NextRequest) {
  try {
    const { email, password } = await request.json();

    // 格式验证
    if (!email || !emailRegex.test(email)) {
      return NextResponse.json({ error: 'Email 输入有误!' }, { status: 400 })
    }
    
    if (!password || !passwordRegex.test(password)) {
      return NextResponse.json({ error: 'password 输入有误!' }, { status: 400 })
    }

    // 查找用户
    const user = await prisma.user.findUnique({
      where: { email }
    });
    
    if (!user) {
      return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
    }

    // 验证密码
    const isPassword = await bcrypt.compare(password, user.password)
    if (!isPassword) {
      return NextResponse.json({ error: 'Invalid credentials' }, { 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 (err) {
    console.error(err)
    return NextResponse.json({ error: 'Interval Server Error!' }, { status: 500 })
  } finally {
    await prisma.$disconnect() // 释放数据库对象
  }
}

登录过程中有几个设计值得注意:一是当用户不存在或密码错误时,返回相同的错误信息"Invalid credentials",这是为了防止攻击者通过不同的错误信息枚举出已存在的用户名。

二是生成Token后,将refreshToken保存到数据库中,这样后面可以验证refreshToken的有效性,并且可以在需要时强制某用户退出登录(只需删除数据库中的refreshToken)。

当我们前端不存在token时,我们访问需要权限的页面时,会将我们重定向到登录页

当我们输入正确的用户名和密码,服务器是会给我们下发accessTokenrefreshToken

Token刷新机制

这是双Token机制最精妙的部分!当accessToken过期后,我们不需要让用户重新登录,而是使用refreshToken获取新的accessToken。这个逻辑在app/api/auth/refresh/route.ts中:

php 复制代码
export async function GET(request: NextRequest) {
  try {
    console.log('refresh---------------------');

    // 从cookie中获取refreshToken
    const refreshToken = request.cookies.get("refreshToken")?.value;
    const redirectUrl = request.nextUrl.searchParams.get('redirect') || "/dashboard";

    if (!refreshToken) {
      return NextResponse.redirect(new URL('/login', request.url));
    }
    
    // 验证refreshToken
    const refreshPayload = await verifyToken(refreshToken);
    if (!refreshPayload || !refreshPayload.userId) {
      return NextResponse.redirect(new URL('/login', request.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', request.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, request.url))
    response.cookies.set('accessToken', newAccessToken, {
      httpOnly: true,
      maxAge: 60 * 15,
      sameSite: 'strict',
      path: '/',
    })
    response.cookies.set('refreshToken', newRefreshToken, {
      httpOnly: true,
      maxAge: 60 * 60 * 24 * 7,
      sameSite: 'strict',
      path: '/',
    })
    
    return response
  } catch (err) {
    console.error('refresh token error', err)
    return NextResponse.redirect(new URL('/login', request.url))
  }
}

这个过程实现了无感刷新,用户完全感知不到Token的更新。关键是每次刷新都会生成新的refreshToken并更新到数据库中,这叫做"refresh token rotation",是一种安全最佳实践,即使旧的refreshToken被泄露,也无法再次使用。

前端登录界面

前端页面使用React构建,位于app/login/page.tsx

javascript 复制代码
'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 }),
      })

      // 处理响应...
    } catch (err) {
      setError('Network error')
    }
  }

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
      <div className="max-w-md w-full space-y-8">
        <div>
          <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
            Sign in
          </h2>
        </div>
        <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
          {error && <div className="text-red-500 text-sm">{error}</div>}
          <div className="rounded-md shadow-sm -space-y-px">
            <div>
              <label htmlFor="email" className="sr-only">Email</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="Email address"
              />
            </div>
            <div>
              <label htmlFor="password" className="sr-only">Password</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="Password"
              />
            </div>
          </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"
            >
              Sign in
            </button>
          </div>
        </form>
      </div>
    </div>
  )
}

这个登录页面使用了React的useState钩子来管理表单状态和错误信息。当用户提交表单时,我们向登录API发送POST请求。如果登录成功,用户会被重定向到dashboard页面。

总结

通过实现这个双Token认证系统,我学到了很多关于Web安全的知识。双Token机制通过短期有效的accessToken和长期有效的refreshToken相结合,既保证了用户体验(无需频繁登录),又提高了安全性(Token泄露风险大大降低)。

关键的安全措施包括:使用HTTP Only和SameSite严格的cookie存储Token、bcrypt加密密码、正则表达式验证输入格式、防止用户枚举攻击、refresh token rotation等。

相关推荐
恋猫de小郭9 小时前
Flutter Zero 是什么?它的出现有什么意义?为什么你需要了解下?
android·前端·flutter
崔庆才丨静觅16 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606117 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了17 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅17 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅17 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅18 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment18 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅18 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊18 小时前
jwt介绍
前端