Node 后端实战:JWT 认证与生产级错误处理
用 TypeScript + Hono 实现一套完整的后端认证与错误处理体系。涵盖 JWT 原理、bcrypt 密码哈希、鉴权中间件、Refresh Token 双令牌机制、自定义错误类、全局错误处理与结构化日志。看完能独立搭建一个生产可用的认证后端。
目录
- 一、为什么需要 JWT
- 二、JWT 的结构与原理
- 三、bcrypt 密码哈希
- 四、注册与登录接口
- 五、鉴权中间件
- 六、Refresh Token 双令牌机制
- 七、自定义错误类体系
- 八、全局错误处理
- 九、结构化日志
- 十、与校验器集成
- 十一、安全细节与常见坑
一、为什么需要 JWT
HTTP 是无状态协议,每个请求相互独立,服务器不会"记得"上一个请求是谁发的。
第 1 个请求:用户登录,服务器验证密码通过
第 2 个请求:用户查数据 —— 服务器:"你是谁?我不认识你"
需要一个机制让服务器在后续请求中认出用户。常见两种方案:
Session(传统) :登录后服务器生成 sessionId,状态存在服务器端(内存/数据库),返回 sessionId 给客户端。后续请求带 sessionId,服务器查"这个 id 对应谁"。
JWT(现代) :登录后服务器生成一个令牌(token),令牌里直接包含用户信息且带签名。后续请求带 token,服务器验证签名就知道是谁,不需要在服务端存储。
两者的核心区别:Session 把状态存在服务端,JWT 把状态存在 token 里。JWT 无状态,适合分布式部署;Session 需要服务端存储,但能即时失效。
二、JWT 的结构与原理
一个 JWT 由三段组成,用 . 分隔:xxxxx.yyyyy.zzzzz
- Header(头部) :说明签名算法,如
{ "alg": "HS256", "typ": "JWT" } - Payload(载荷) :存用户信息和过期时间,如
{ "userId": "...", "email": "...", "exp": 1234567890 } - Signature(签名) :用密钥对 Header + Payload 签名
一个关键认知
JWT 的 Payload 不是加密的,只是 Base64 编码,任何人都能解开看到内容。所以不要在 Payload 里放密码等敏感信息。
JWT 的安全性靠的是签名:如果有人篡改了 Payload(比如把 userId 改成别人的),签名就对不上,服务器会拒绝。因为生成签名需要密钥,攻击者没有密钥就无法伪造有效签名。
完整认证流程
① 注册:用户提交邮箱+密码 → 服务器把密码哈希后存库
② 登录:用户提交邮箱+密码 → 服务器比对密码哈希 → 通过则生成 JWT 返回
③ 访问受保护接口:请求头带 Authorization: Bearer <token>
→ 中间件验证签名 → 通过则放行,失败则 401
三、bcrypt 密码哈希
绝不能存明文密码。即使数据库泄露,也不能让攻击者直接拿到密码。
为什么用 bcrypt 而不是普通哈希
普通哈希(如 MD5、SHA256)有个问题:同样的密码哈希结果相同。攻击者可以预先算好海量"密码→哈希"对照表(彩虹表),拿到哈希一查就破。
bcrypt 的解法是自动加盐(salt) :每次哈希生成一个随机 salt,所以同样的密码每次哈希结果都不同,彩虹表失效。验证时,bcrypt 从存储的哈希里取出 salt 重新计算再比对。
实现
import bcrypt from 'bcryptjs'
export async function hashPassword(password: string): Promise<string> {
const saltRounds = 10 // 哈希强度,10 是常用值
return bcrypt.hash(password, saltRounds)
}
export async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
return bcrypt.compare(password, hash)
}
注意用 bcryptjs(纯 JS 实现,跨平台无需编译)而非 bcrypt(需要本地编译)。saltRounds 越大越安全但越慢,10 是性能与安全的平衡点。
四、注册与登录接口
users 表设计
export const users = pgTable('users', {
id: uuid('id').defaultRandom().primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
name: varchar('name', { length: 100 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
})
两个设计点:email 加 .unique(),数据库层保证邮箱不重复;字段名叫 passwordHash 而非 password,提醒"这里存的是哈希"。
注册接口
auth.post('/register', zValidator('json', RegisterSchema), async (c) => {
const { email, password, name } = c.req.valid('json')
// 检查邮箱是否已注册
const [existing] = await db
.select().from(users).where(eq(users.email, email)).limit(1)
if (existing) {
throw new ConflictError('该邮箱已被注册')
}
// 哈希密码后创建用户
const passwordHash = await hashPassword(password)
const [newUser] = await db
.insert(users).values({ email, passwordHash, name }).returning()
if (!newUser) {
throw new Error('用户创建失败')
}
// 签发 token
const { accessToken, refreshToken } = generateTokenPair({
userId: newUser.id,
email: newUser.email,
})
// 返回:注意不返回 passwordHash
return c.json({
data: {
user: { id: newUser.id, email: newUser.email, name: newUser.name },
accessToken,
refreshToken,
},
}, 201)
})
两个安全细节
不返回 passwordHash:返回 user 时只挑需要的字段,不要直接 return user,否则会把哈希泄露给客户端。
登录失败统一报错:用户不存在和密码错误,都返回"邮箱或密码错误",不要分别提示。否则攻击者可以用报错信息探测哪些邮箱已注册。
auth.post('/login', zValidator('json', LoginSchema), async (c) => {
const { email, password } = c.req.valid('json')
const [user] = await db
.select().from(users).where(eq(users.email, email)).limit(1)
// 用户不存在 —— 不要说"用户不存在"
if (!user) {
throw new UnauthorizedError('邮箱或密码错误')
}
// 密码错误 —— 同样的提示
const valid = await verifyPassword(password, user.passwordHash)
if (!valid) {
throw new UnauthorizedError('邮箱或密码错误')
}
const { accessToken, refreshToken } = generateTokenPair({
userId: user.id,
email: user.email,
})
return c.json({
data: {
user: { id: user.id, email: user.email, name: user.name },
accessToken,
refreshToken,
},
})
})
注:
const [user] = await db.select()...这种数组解构,在 TypeScript 严格模式(noUncheckedIndexedAccess)下首项类型是T | undefined,访问属性前必须先判空。
五、鉴权中间件
中间件是请求到达路由前先经过的"关卡"。鉴权中间件验证 token,通过则把用户信息挂到 context 供后续路由使用。
import { createMiddleware } from 'hono/factory'
export const authMiddleware = createMiddleware<{
Variables: { user: JwtPayload }
}>(async (c, next) => {
// 取 Authorization 头
const authHeader = c.req.header('Authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new UnauthorizedError('未提供有效的认证令牌')
}
// 取出 token(去掉 "Bearer " 前缀)
const token = authHeader.slice(7)
// 验证 token
try {
const payload = verifyAccessToken(token)
c.set('user', payload) // 挂到 context
} catch {
throw new UnauthorizedError('认证令牌无效或已过期')
}
await next() // 放行
})
c.set / c.get 是 context 上的"共享数据袋":中间件验证完身份后 c.set('user', payload) 放进去,后续路由 c.get('user') 取出来用,不用每个路由重新验证。createMiddleware 的泛型 Variables 声明了袋子里有什么,让 c.get('user') 有完整类型推导。
挂载顺序
// 不需要登录的路由
app.route('/auth', authRouter)
// 需要登录的路由:中间件必须在 route 之前
app.use('/chats/*', authMiddleware)
app.use('/rag/*', authMiddleware)
app.route('/chats', chatRouter)
app.route('/rag', ragRouter)
app.use 必须写在 app.route 之前,否则中间件不生效。/* 表示匹配该前缀下所有路径。
六、Refresh Token 双令牌机制
单令牌的困境
只发一个 token 时面临两难:有效期长则泄露风险大,有效期短则用户要频繁重新登录。
双令牌方案
生产标准做法是发两个 token:
- access token:短命(15 分钟 - 1 小时),带在每个请求里
- refresh token:长命(7 - 30 天),只用来换取新的 access token
流程:access token 过期后,客户端用 refresh token 调 /auth/refresh 换一个新的 access token,用户无感知。
好处:access token 短命,即使泄露损失窗口也小;refresh token 不频繁传输,泄露概率低;用户不用频繁登录。
实现
const ACCESS_TOKEN_EXPIRES = '1h'
const REFRESH_TOKEN_EXPIRES = '30d'
export function generateAccessToken(payload: JwtPayload): string {
return jwt.sign(payload, JWT_SECRET, { expiresIn: ACCESS_TOKEN_EXPIRES })
}
export function generateRefreshToken(userId: string): string {
// refresh token 的 payload 带 type 字段做区分
return jwt.sign({ userId, type: 'refresh' }, JWT_SECRET, {
expiresIn: REFRESH_TOKEN_EXPIRES,
})
}
export function verifyRefreshToken(token: string) {
const payload = jwt.verify(token, JWT_SECRET) as RefreshPayload
// 关键:确认这是 refresh token,防止 access token 冒充
if (payload.type !== 'refresh') {
throw new Error('不是有效的 refresh token')
}
return payload
}
export function generateTokenPair(payload: JwtPayload) {
return {
accessToken: generateAccessToken(payload),
refreshToken: generateRefreshToken(payload.userId),
}
}
refresh token 的 payload 带一个 type: 'refresh' 标记,验证时检查这个标记,防止有人拿 access token 冒充 refresh token。
refresh 端点
auth.post('/refresh', zValidator('json', RefreshSchema), async (c) => {
const { refreshToken } = c.req.valid('json')
// 验证 refresh token
let payload
try {
payload = verifyRefreshToken(refreshToken)
} catch {
throw new UnauthorizedError('refresh token 无效或已过期')
}
// 确认用户还存在
const [user] = await db
.select().from(users).where(eq(users.id, payload.userId)).limit(1)
if (!user) {
throw new UnauthorizedError('用户不存在')
}
// 签发新的 access token
const accessToken = generateAccessToken({
userId: user.id,
email: user.email,
})
return c.json({ data: { accessToken } })
})
JWT 的固有局限
JWT 无状态,没法主动"撤销"一个已签发的 token。用户登出或改密码后,旧 token 在过期前仍然有效。生产中的应对:
- 维护一个黑名单(用 Redis),登出的 token 进黑名单,中间件查黑名单
- 或在 token 里存版本号,改密码时版本号 +1,旧 token 版本对不上即失效
- 或干脆用很短的 access token 有效期,容忍一个小的风险窗口
这是 JWT 相比 Session 的固有权衡。
七、自定义错误类体系
散落式错误处理的问题
如果每个路由各自 return c.json({ error: '...' }, 状态码),会出现:错误处理散落各处、响应格式不统一、没有错误码(前端只能靠 message 字符串判断)、真出异常时用户看到一坨堆栈。
生产做法:定义错误类型 → 路由里 throw → 全局处理器统一捕获。
错误基类
export class AppError extends Error {
public readonly statusCode: number // HTTP 状态码
public readonly code: string // 业务错误码
public readonly isOperational: boolean // 是否可预期的业务错误
constructor(message: string, statusCode: number, code: string, isOperational = true) {
super(message)
this.name = this.constructor.name
this.statusCode = statusCode
this.code = code
this.isOperational = isOperational
Error.captureStackTrace(this, this.constructor)
}
}
三个字段各有用途:statusCode 是 HTTP 状态码;code 是业务错误码字符串,前端靠它精确判断(比"邮箱已注册"这种 message 可靠);isOperational 区分"可预期的业务错误"(如邮箱重复,正常,不用告警)和"系统级 bug"(如数据库挂了,需要告警)。
常用子类
export class BadRequestError extends AppError {
constructor(message = '请求参数有误') {
super(message, 400, 'BAD_REQUEST')
}
}
export class UnauthorizedError extends AppError {
constructor(message = '未认证或登录已过期') {
super(message, 401, 'UNAUTHORIZED')
}
}
export class NotFoundError extends AppError {
constructor(message = '请求的资源不存在') {
super(message, 404, 'NOT_FOUND')
}
}
export class ConflictError extends AppError {
constructor(message = '资源冲突') {
super(message, 409, 'CONFLICT')
}
}
有了这些,路由里就从"自己拼格式"变成"只描述错误":
// 之前:每处自己写格式 + 状态码
return c.json({ error: 'Chat not found' }, 404)
// 之后:格式、状态码、日志全交给全局处理器
throw new NotFoundError('对话不存在')
八、全局错误处理
挂在 app.onError 上,捕获所有路由抛出的异常,分情况处理。
export function errorHandler(err: Error, c: Context) {
// 情况 1:自定义 AppError
if (err instanceof AppError) {
const logFn = err.isOperational ? logger.warn : logger.error
logFn.call(logger, {
code: err.code,
path: c.req.path,
msg: err.message,
})
return c.json(
{ error: { code: err.code, message: err.message } },
err.statusCode as ContentfulStatusCode
)
}
// 情况 2:未预料的错误(系统级 bug)
logger.error({
path: c.req.path,
err: { message: err.message, stack: err.stack },
}, 'Unhandled error')
const isDev = process.env.NODE_ENV !== 'production'
return c.json({
error: {
code: 'INTERNAL_ERROR',
// 生产环境不暴露内部细节
message: isDev ? err.message : '服务器内部错误,请稍后重试',
},
}, 500)
}
挂载时放在所有路由之后:
const app = new Hono()
// ... 路由 ...
app.onError(errorHandler)
Dev 与 Prod 的关键区别
开发环境返回 err.message 甚至堆栈,方便调试;生产环境只返回"服务器内部错误",不暴露内部实现细节和数据库结构,避免给攻击者提供信息。
九、结构化日志
console.log 在生产环境不够用:纯文本机器无法解析、没有日志级别、没有结构化字段。pino 是高性能的结构化日志库,输出 JSON,能被日志系统(ELK、Loki 等)解析检索。
import pino from 'pino'
const isDev = process.env.NODE_ENV !== 'production'
export const logger = pino({
level: isDev ? 'debug' : 'info',
// 开发环境美化输出,生产环境输出原始 JSON
transport: isDev
? { target: 'pino-pretty', options: { colorize: true } }
: undefined,
})
用法:
logger.info({ userId: '123' }, '用户登录成功')
logger.warn({ code: 'BAD_REQUEST', path: '/auth/register' }, '校验失败')
logger.error({ err: { message, stack } }, 'Unhandled error')
pino 的约定:第一个参数是结构化字段对象,第二个参数是日志消息。这样日志既有人类可读的消息,又有可检索的结构化字段。
十、与校验器集成
一个容易踩的坑:@hono/zod-validator 默认会自己拦截校验失败的 ZodError,直接返回它自己的格式,根本不会抛到全局 onError。结果是校验错误的响应格式跟其他错误不一致。
解法是给 zValidator 传第三个参数(错误钩子),在钩子里 throw 自定义错误。封装成一个自己的 zValidator 复用:
import { zValidator as zv } from '@hono/zod-validator'
import { BadRequestError } from './errors.js'
export function zValidator(
target: Parameters<typeof zv>[0],
schema: Parameters<typeof zv>[1]
) {
return zv(target, schema, (result) => {
if (!result.success) {
const details = result.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
}))
throw new BadRequestError(
'请求参数校验失败: ' +
details.map((d) => `${d.field}(${d.message})`).join(', ')
)
}
})
}
这里用 Parameters<typeof zv> 借用原函数的参数类型,不手写泛型——这样底层库的类型定义变化时不容易受影响。然后路由里把 import 从 @hono/zod-validator 换成自己封装的这个,校验失败就会走全局错误处理,响应格式统一。
十一、安全细节与常见坑
安全清单
- 密码必须哈希存储,用 bcrypt 自动加盐
- JWT Payload 不放敏感信息(它只是 Base64 编码,不是加密)
- 登录失败统一报"邮箱或密码错误",防邮箱探测
- 返回 user 时不带 passwordHash
- access token 短命 + refresh token 机制
JWT_SECRET用强随机值(openssl rand -base64 32生成),且不进版本控制- 生产环境错误响应不暴露堆栈和内部细节
- 登录接口加限流,防暴力破解
常见坑
中间件不生效:app.use 写在了 app.route 之后,顺序反了。
校验错误格式不统一:@hono/zod-validator 默认拦截 ZodError,需要传错误钩子才能进全局 onError。
数组解构后访问属性报错:严格模式下 const [x] = arr 的 x 类型是 T | undefined,访问属性前要判空。
JWT 无法主动失效:这不是 bug 是固有特性,登出/改密码场景需要黑名单或版本号机制。
小结
一套生产级认证 + 错误处理体系包含:
认证:
注册 → bcrypt 哈希 → 存库
登录 → 比对哈希 → 签发 access + refresh token
访问 → 中间件验证 access token
过期 → refresh 端点换新 token
错误处理:
自定义错误类(AppError + 子类)
→ 路由里 throw
→ 全局 onError 统一捕获、统一格式、结构化日志
→ Dev 给详情,Prod 不暴露细节
核心思想是"统一":一处定义错误类型,一处处理,响应格式一致,日志结构化。认证部分则要在"安全"和"用户体验"之间做权衡——双令牌机制正是这种权衡的产物。
参考资源
- JWT 官方:jwt.io
- jsonwebtoken:github.com/auth0/node-…
- bcrypt 介绍:en.wikipedia.org/wiki/Bcrypt
- pino 日志:github.com/pinojs/pino
- Hono 中间件文档:hono.dev/docs/guides…