双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等。

相关推荐
怪可爱的地球人7 小时前
Symbol符号是“唯一性”的类型
前端
月亮慢慢圆7 小时前
cookie,session和token的区别和用途
前端
郭邯7 小时前
vant-weapp源码解读(3)
前端·微信小程序
golang学习记7 小时前
从0 死磕全栈第3天:React Router (Vite + React + TS 版):构建小时站实战指南
前端
Dream耀7 小时前
Promise静态方法解析:从并发控制到竞态处理
前端·javascript·代码规范
JarvanMo7 小时前
2025 年真正有效的 App Store 优化(ASO)
前端·ios
{⌐■_■}7 小时前
【JavaScript】前端两种路由模式,Hash路由,History 路由
前端·javascript·哈希算法
前端老鹰7 小时前
HTML `<datalist>`:原生下拉搜索框,无需 JS 也能实现联想功能
前端·html