今天我要分享一个让我眼前一亮的技术方案------双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时,我们访问需要权限的页面时,会将我们重定向到登录页

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

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