Node 后端实战:JWT 认证与生产级错误处理

13 阅读12分钟

Node 后端实战:JWT 认证与生产级错误处理

用 TypeScript + Hono 实现一套完整的后端认证与错误处理体系。涵盖 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] = arrx 类型是 T | undefined,访问属性前要判空。

JWT 无法主动失效:这不是 bug 是固有特性,登出/改密码场景需要黑名单或版本号机制。


小结

一套生产级认证 + 错误处理体系包含:

认证:
  注册 → bcrypt 哈希 → 存库
  登录 → 比对哈希 → 签发 access + refresh token
  访问 → 中间件验证 access token
  过期 → refresh 端点换新 token

错误处理:
  自定义错误类(AppError + 子类)
  → 路由里 throw
  → 全局 onError 统一捕获、统一格式、结构化日志
  → Dev 给详情,Prod 不暴露细节

核心思想是"统一":一处定义错误类型,一处处理,响应格式一致,日志结构化。认证部分则要在"安全"和"用户体验"之间做权衡——双令牌机制正是这种权衡的产物。


参考资源