前言
在现代 Web 应用中,用户登录鉴权是前端开发必修课。尤其是在企业级项目中,我们常常面临这些问题:
- 如何保证用户登录后页面长期保持登录状态?
- accessToken 过期怎么办?刷新 token 怎么安全实现?
- 如何保证安全性又兼顾用户体验?
今天,我将结合 Next.js + Prisma + JWT + 双 Token 的实战经验,详细讲解如何实现一个 完整的安全登录系统。文章会从数据库设计、token 生成、前端中间件、无感刷新逻辑到面试加分点,全方位拆解,让小白也能读懂。
一、双 Token 登录概念
双 Token 登录是一种现代 Web 应用常用方案,核心理念是:
| Token | 生命周期 | 存储位置 | 作用 |
|---|---|---|---|
| Access Token | 短期(10--30 分钟) | HttpOnly Cookie 或请求 Header | 用于接口鉴权,每次请求验证用户身份 |
| Refresh Token | 长期(7 天甚至更长) | HttpOnly Cookie + 数据库 | 用于生成新的 accessToken,实现无感刷新 |
优势:
- 安全性高:accessToken 过期快,即使被盗也影响有限
- 用户体验好:refreshToken 可实现无感刷新,用户无需频繁登录
- 可控性强:数据库存储 refreshToken,可随时注销或失效
二、Next.js 路由与 dashboard 目录
在 Next.js 中,每个目录对应路由,dashboard 目录常用于登录后可访问的后台页面集合:
bash
app/
├─ dashboard/
│ ├─ page.tsx → /dashboard
│ ├─ settings/page.tsx → /dashboard/settings
│ └─ users/page.tsx → /dashboard/users
├─ login/page.tsx → /login
├─ layout.tsx
特点:
- dashboard 下的页面都是 受保护路由
- 可以在
dashboard/layout.tsx统一布局(侧边栏、导航栏) - 中间件可以统一处理整个 dashboard 的鉴权逻辑
双 Token 体系结合数据库存储和中间件无感刷新,实现了安全、可控和高用户体验的登录方案:accessToken 短期保证安全,refreshToken 长期保证无感刷新,同时数据库存储 refreshToken 可以随时注销或轮换,支持多端管理
dashboard 目录的作用
在我们的双 Token 登录系统中,dashboard 目录扮演了核心角色,它是所有登录后可访问页面的集合。理解 dashboard 的作用,对实现安全、可控的登录体系非常重要。
受保护路由
- dashboard 下的所有页面都是登录后才能访问的路由。
- 如果用户没有登录(accessToken 和 refreshToken 都无效),中间件会拦截请求,并重定向到登录页。
- 举例目录结构:
bash
app/
├─ dashboard/
│ ├─ page.tsx → /dashboard
│ ├─ settings/page.tsx → /dashboard/settings
│ └─ users/page.tsx → /dashboard/users
逻辑解析:
- 用户请求
/dashboard或子页面 - 中间件检查 accessToken 是否有效
- accessToken 有效 → 允许访问
- accessToken 无效但 refreshToken 有效 → 自动刷新 token,无感登录
- 两者都无效 → 重定向登录页
面试加分点:dashboard 目录就是"受保护路由集合",中间件统一管理鉴权,保证安全性和用户体验。
统一布局(layout.tsx)
在 Next.js 中,dashboard/layout.tsx 可以统一配置所有子页面的布局,比如:
- 左侧导航栏(菜单)
- 顶部导航(用户信息、登出按钮)
- 内容区域(子页面渲染)
示例:
javascript
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="dashboard-layout">
<aside className="sidebar">导航菜单</aside>
<header className="header">用户信息 / 登出</header>
<main className="content">{children}</main>
</div>
)
}
优势:
- 所有 dashboard 子页面统一样式和布局
- 页面切换不会重复加载导航和侧边栏
3️⃣ 中间件统一鉴权
中间件可以针对整个 dashboard 目录进行统一鉴权:
csharp
export async function middleware(request: NextRequest) {
const accessToken = request.cookies.get('accessToken')?.value
const refreshToken = request.cookies.get('refreshToken')?.value
if (accessToken) {
const payload = await verifyToken(accessToken)
if (payload) {
const headers = new Headers(request.headers)
headers.set('x-user-id', payload.userId as string)
return NextResponse.next({ request: { headers } })
}
}
if (refreshToken) {
const payload = await verifyToken(refreshToken)
if (payload) {
const refreshUrl = new URL('/api/auth/refresh', request.url)
refreshUrl.searchParams.set('redirect', request.url)
return NextResponse.redirect(refreshUrl)
}
}
return NextResponse.redirect(new URL('/login', request.url))
}
解析:
- 中间件在请求到达 dashboard 子页面前执行
- accessToken 有效 → 注入
x-user-idheader → 页面和 API 可以直接使用用户信息 - accessToken 无效 + refreshToken 有效 → 重定向
/api/auth/refresh→ 无感刷新 - 两者都无效 → 强制重定向登录页
- 用中间件统一处理鉴权逻辑,避免每个页面重复代码
- 将用户信息注入 header,提高下游 API 访问效率
- 支持无感刷新,提高用户体验
三、Prisma 数据库设计
Prisma 用于管理用户和内容表:
kotlin
model users {
id Int @id @default(autoincrement())
email String @unique
password String
refreshToken String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
posts Post[]
}
model Post {
id Int @id @default(autoincrement())
title String
content String
published Boolean @default(false)
author users @relation(fields: [authorId], references: [id])
authorId Int
createdAt DateTime @default(now())
}
关键点:
-
refreshToken 存数据库:保证登录态可控,支持登出和多端管理
-
Prisma Migration 区分数据和结构:
- 修改表结构或新增字段 → 需要迁移(migration)
- 插入或更新数据(如新增用户或刷新 token) → 不需要迁移
-
password 使用 bcrypt 加密存储
refreshToken 存数据库:保证登录态可控,支持登出和多端管理
原理
- refreshToken 是长期有效的 token,存储在浏览器 Cookie 中
- 如果只存前端,服务端无法知道用户是否登出或者是否失效
- 因此,我们在 Prisma users 表中存储 refreshToken
kotlin
model users {
id Int @id @default(autoincrement())
email String @unique
password String
refreshToken String? // 存储 refreshToken
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
优势
-
支持单端登出
- 用户在一台设备登出 → 清空数据库 refreshToken
- 其他设备尝试刷新 accessToken 时会失败 → 强制重新登录
-
支持 token 轮换
- 每次刷新 accessToken 时生成新的 refreshToken
- 数据库存储最新的 refreshToken
- 防止旧 token 被重放攻击
-
支持多端管理
- 不同设备登录可以生成不同 refreshToken
- 可以按设备控制是否失效
面试亮点:
- "数据库存储 refreshToken 让服务端完全掌控登录状态,支持登出、轮换和多端管理。"
Prisma Migration 区分数据和结构
在开发中,有些操作会修改数据库内容,有些会修改数据库结构,需要区分:
结构变更(需要 Migration)
- 新增字段,例如
users表新增lastLoginTime - 修改字段类型或约束
- 新增或修改表
Prisma 命令:
csharp
npx prisma migrate dev --name add-lastLoginTime
生成 SQL 并更新数据库结构
数据操作(不需要 Migration)
-
插入或更新数据,如:
- 新用户注册
- 登录后更新 refreshToken
- 修改用户资料
css
await prisma.users.update({
where: { id: user.id },
data: { refreshToken: newRefreshToken }
})
这些操作不会改变表结构,不需要迁移
小结
- Migration = 改结构
- 数据操作 = 改数据
- 面试回答示例:
"Prisma 区分表结构和数据操作,只有结构变更才需要迁移,数据更新不需要迁移,这保证了开发效率和数据库安全。"
** password 使用 bcrypt 加密存储**
直接存明文密码是非常危险的,一旦数据库泄露,用户账号就会被盗用。
bcrypt 加密流程
- 注册时:
javascript
import bcrypt from 'bcrypt'
const hashedPassword = await bcrypt.hash(password, 10)
await prisma.users.create({
data: { email, password: hashedPassword }
})
10是 saltRounds,表示加密复杂度- 生成的
hashedPassword是不可逆的
- 登录验证时:
vbnet
const isPasswordValid = await bcrypt.compare(password, user.password)
if (!isPasswordValid) {
return NextResponse.json({ error: '密码错误' }, { status: 401 })
}
compare将用户输入密码和数据库加密密码比对- 即使数据库泄露,攻击者无法还原明文密码
优势
-
安全:不可逆,加密算法抗暴力破解
-
面试亮点:
- "使用 bcrypt 对密码加盐哈希存储,保障用户数据安全,是大厂必备做法"
四、bcrypt 密码加密
注册或登录时,使用 bcrypt 处理密码:
vbnet
const isPasswordValid = await bcrypt.compare(password, user.password)
if (!isPasswordValid) {
return NextResponse.json({ error: '密码错误,登录失败' }, { status: 401 })
}
解析:
- bcrypt 是不可逆加密算法,保证数据库中不会存储明文密码
compare用于验证用户输入密码是否匹配加密后的密码
面试回答:
"使用 bcrypt 对密码进行哈希,加密后的密码存储在数据库,防止明文泄露,保障安全。"
五、生成双 Token 与存储 refreshToken
登录成功后,生成 accessToken 和 refreshToken,并存储 refreshToken:
python
const { accessToken, refreshToken } = await createToken(user.id)
await prisma.users.update({
where: { id: user.id },
data: { refreshToken }
})
setAuthCookies(accessToken, refreshToken)
解析:
-
createToken(user.id)→ 返回两个 token-
accessToken:短期有效
- 用于 API 鉴权,通常有效期 10--30 分钟
- 存储在前端 HttpOnly Cookie 或请求 Header 中
- 过期后无法使用,保证安全性,即使被盗也影响有限
-
refreshToken:长期有效,存数据库
- 用于生成新的 accessToken,实现无感刷新
- 有效期通常 7 天或更长
- 存数据库保证服务端可控,支持登出、轮换、多端管理
-
-
prisma.users.update→ 更新数据库中的 refreshToken-
每次用户登录或 refreshToken 刷新时,都更新数据库中的 refreshToken
-
保证每次刷新都能轮换 token
- 老的 refreshToken 立即失效
- 防止重放攻击(Replay Attack)
- 多端登录时可控制单端失效
-
数据库存储的 refreshToken 与用户浏览器中的 refreshToken 一一对应,保证安全可控
-
-
setAuthCookies→ 存储 token 为 HttpOnly Cookie-
防 XSS(跨站脚本攻击)
- HttpOnly Cookie 浏览器 JavaScript 无法访问
- 防止前端脚本窃取 token
-
配置
sameSite: strict防 CSRF(跨站请求伪造)- 浏览器只在同源请求中发送 Cookie
- 防止第三方网站伪造请求访问你的接口
-
示例配置:
phpresponse.cookies.set('accessToken', accessToken, { httpOnly: true, maxAge: 60 * 15, // 15 分钟 sameSite: 'strict', path: '/' }) response.cookies.set('refreshToken', refreshToken, { httpOnly: true, maxAge: 60 * 60 * 24 * 7, // 7 天 sameSite: 'strict', path: '/' }) -
总结:通过
createToken生成双 token、更新数据库 refreshToken 并用 HttpOnly Cookie 存储,既保证了安全性,又实现了无感刷新,用户体验与安全性兼顾。
六、中间件鉴权 + 无感刷新
核心中间件逻辑:
csharp
if (accessToken) {
const accessPayload = await verifyToken(accessToken)
if (accessPayload) {
const requestHeaders = new Headers(request.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', request.url)
refreshUrl.searchParams.set('redirect', request.url)
return NextResponse.redirect(refreshUrl)
}
}
// 两个 token 都无效
return NextResponse.redirect(new URL('/login', request.url))
逐步解析:
1️⃣ accessToken 有效
-
verifyToken(accessToken)→ 解码 token- 验证 accessToken 是否过期
- 解析出用户信息(例如
userId)
-
如果有效:
-
克隆请求头 :
new Headers(request.headers)- 原始
request.headers是只读的,需要克隆才能修改 - 克隆后可以安全地注入自定义 header
- 原始
-
添加
x-user-id→ 下游 API 可直接读取- 让后端接口不必每次都解析 accessToken
- 下游服务可以通过
x-user-id知道当前操作的用户
-
NextResponse.next()→ 请求继续执行- 页面正常访问,无需跳转或刷新
- 提升用户体验
-
优势:下游无需重复解析 token,即可获取用户 ID,访问速度快,安全且高效
2️⃣ accessToken 无效,refreshToken 有效
-
解码 refreshToken
- 验证 refreshToken 是否有效
- 确认用户身份信息
-
如果有效:
-
重定向到
/api/auth/refresh- 后端生成 新的 accessToken + refreshToken
- 更新数据库中的 refreshToken,实现 token 轮换
-
页面无感知 → "无感刷新"
- 用户不会看到登录提示
- 页面继续访问 dashboard 或其他受保护页面
- 无感刷新提高用户体验
-
这就是双 Token 无感刷新核心逻辑
- accessToken 失效 → refreshToken 自动刷新
- 用户无感知
- 保证安全性 + 用户体验兼顾
3️⃣ 两个 token 都无效
-
重定向登录页 → 用户必须重新登录
- 这是最后一道安全防线
- 防止非法访问 dashboard 或 API
-
保证安全性
- accessToken 和 refreshToken 都无效,说明用户会话已经过期
- 强制登录,确保系统不会被未授权访问
七、accessToken header 注入详解
php
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-user-id', accessPayload.userId as string)
return NextResponse.next({ request: { headers: requestHeaders } })
-
为什么要这么做?
- 原始
request.headers是只读的,必须克隆 - 注入
x-user-id方便后续 API 直接使用 - 安全:header 在服务端传递,前端无法篡改
- 原始
面试回答:
"这种方式保证了线程安全,同时让下游请求无需解析 token 就能获取用户身份。"
八、有感刷新 VS 无感刷新
| 类型 | 用户体验 | 实现方式 |
|---|---|---|
| 有感刷新 | 用户看到登录提示,需要重新输入密码 | token 过期 → 弹登录框或跳转登录页 |
| 无感刷新 | 用户无感知,页面一直可用 | accessToken 过期 → refreshToken 自动刷新 → 页面继续访问 |
- 无感刷新 是现代 SPA / Next.js 应用标准做法
- 提升用户体验,同时保证安全性
九、总结
- 双 Token 机制:accessToken + refreshToken
- refreshToken 存数据库:支持失效、轮换和单端登出
- 中间件统一鉴权 :注入
x-user-id,实现无感刷新 - HttpOnly Cookie:防 XSS / CSRF
- Prisma Migration:表结构变更才需要 migration,数据操作无需迁移
- 流程清晰:注册 → 登录 → token 存储 → 鉴权 → 无感刷新 → 页面访问
面试高分回答:
"Next.js + Prisma + 双 Token 登录 + 中间件无感刷新,实现了安全、可控、用户无感知的登录体系,accessToken 短期有效保证安全,refreshToken 长期有效支持无感刷新,Prisma 数据库存储 refreshToken 支持多端管理。"
可视化流程
-
登录流程图
- 用户输入账号密码 → 后端验证 → 返回 accessToken + refreshToken → 存 HttpOnly Cookie
-
访问 dashboard 流程图
- 请求 dashboard → 中间件验证 accessToken
- accessToken 有效 → 注入 x-user-id → 页面继续访问
- accessToken 无效 + refreshToken 有效 → redirect
/api/auth/refresh→ 后端刷新 token → 页面无感刷新 - 两者无效 → redirect
/login
-
Token 生命周期图
- accessToken 短期,refreshToken 长期
- 每次刷新轮换 refreshToken
- 数据库存储保证可控
🔥 总结
通过这篇文章,你可以完整掌握:
- Next.js 双 Token 登录体系
- Prisma 数据库 + refreshToken 存储
- bcrypt 密码加密
- 中间件鉴权 + 无感刷新实现
- 有感刷新 vs 无感刷新
一句话总结:
Next.js + Prisma + 双 Token + 中间件无感刷新 = 安全、可控、高体验的现代登录鉴权体系