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

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

用 TypeScript + Hono 实现一套完整的后端认证与错误处理体系。涵盖 JWT 原理、bcrypt 密码哈希、鉴权中间件、Refresh Token 双令牌机制、自定义错误类、全局错误处理与结构化日志。看完能独立搭建一个生产可用的认证后端。

目录


一、为什么需要 JWT

HTTP 是无状态协议,每个请求相互独立,服务器不会"记得"上一个请求是谁发的。

arduino 复制代码
第 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 改成别人的),签名就对不上,服务器会拒绝。因为生成签名需要密钥,攻击者没有密钥就无法伪造有效签名。

完整认证流程

sql 复制代码
① 注册:用户提交邮箱+密码 → 服务器把密码哈希后存库
② 登录:用户提交邮箱+密码 → 服务器比对密码哈希 → 通过则生成 JWT 返回
③ 访问受保护接口:请求头带 Authorization: Bearer <token>
   → 中间件验证签名 → 通过则放行,失败则 401

三、bcrypt 密码哈希

绝不能存明文密码。即使数据库泄露,也不能让攻击者直接拿到密码。

为什么用 bcrypt 而不是普通哈希

普通哈希(如 MD5、SHA256)有个问题:同样的密码哈希结果相同。攻击者可以预先算好海量"密码→哈希"对照表(彩虹表),拿到哈希一查就破。

bcrypt 的解法是自动加盐(salt) :每次哈希生成一个随机 salt,所以同样的密码每次哈希结果都不同,彩虹表失效。验证时,bcrypt 从存储的哈希里取出 salt 重新计算再比对。

实现

typescript 复制代码
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 表设计

css 复制代码
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,提醒"这里存的是哈希"。

注册接口

scss 复制代码
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,否则会把哈希泄露给客户端。

登录失败统一报错:用户不存在和密码错误,都返回"邮箱或密码错误",不要分别提示。否则攻击者可以用报错信息探测哪些邮箱已注册。

php 复制代码
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 供后续路由使用。

dart 复制代码
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') 有完整类型推导。

挂载顺序

php 复制代码
// 不需要登录的路由
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 不频繁传输,泄露概率低;用户不用频繁登录。

实现

javascript 复制代码
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 端点

csharp 复制代码
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 → 全局处理器统一捕获。

错误基类

typescript 复制代码
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"(如数据库挂了,需要告警)。

常用子类

scala 复制代码
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')
  }
}

有了这些,路由里就从"自己拼格式"变成"只描述错误":

php 复制代码
// 之前:每处自己写格式 + 状态码
return c.json({ error: 'Chat not found' }, 404)

// 之后:格式、状态码、日志全交给全局处理器
throw new NotFoundError('对话不存在')

八、全局错误处理

挂在 app.onError 上,捕获所有路由抛出的异常,分情况处理。

php 复制代码
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)
}

挂载时放在所有路由之后:

scss 复制代码
const app = new Hono()
// ... 路由 ...
app.onError(errorHandler)

Dev 与 Prod 的关键区别

开发环境返回 err.message 甚至堆栈,方便调试;生产环境只返回"服务器内部错误",不暴露内部实现细节和数据库结构,避免给攻击者提供信息。


九、结构化日志

console.log 在生产环境不够用:纯文本机器无法解析、没有日志级别、没有结构化字段。pino 是高性能的结构化日志库,输出 JSON,能被日志系统(ELK、Loki 等)解析检索。

php 复制代码
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,
})

用法:

php 复制代码
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 复用:

javascript 复制代码
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 是固有特性,登出/改密码场景需要黑名单或版本号机制。


小结

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

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

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

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


参考资源

相关推荐
Master_Azur4 小时前
单元测试——Junit单元测试框架
后端
用户8356290780514 小时前
使用 Python 进行 Word 邮件合并
后端
莽夫搞战术4 小时前
【Google Stitch】AI原生画布重新定义设计,让想法变成可交互界面
前端·人工智能·ui
用户8356290780514 小时前
Python 操作 PowerPoint OLE 对象
后端·python
甲维斯4 小时前
Gemini3.5Flash前端是真的强!
前端·人工智能
光泽雨4 小时前
c#中的Type类型
开发语言·前端
Captaincc5 小时前
来自 Codex 官方团队的分享:如何把 Codex 用到极致
前端·vibecoding
lichenyang4535 小时前
鸿蒙聊天 Demo 练习 05:新增登录功能,实现登录态保存与页面访问控制
前端
还有多久拿退休金5 小时前
我用 Three.js 造了个 3D 漫步世界,角色走路像喝醉了——以及我是怎么修好的
前端·vue.js