Chapter 09

认证与安全

JWT 完整流程、OAuth2 集成、Rate Limiting 与安全 HTTP Headers——构建生产级安全 API

JWT 认证

Hono 内置了 JWT 中间件(hono/jwt),基于 Web Crypto API 实现,无需任何 Node.js 依赖,在所有运行时上均可使用:

# 安装(如需密码哈希)
bun add bcryptjs
bun add -D @types/bcryptjs

完整登录注册流程

// src/routes/auth.ts
import { Hono } from 'hono'
import { sign, verify } from 'hono/jwt'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import bcrypt from 'bcryptjs'

type Env = {
  Bindings: { JWT_SECRET: string; DB: D1Database }
  Variables: { userId: string; userEmail: string }
}

const auth = new Hono<Env>()

const registerSchema = z.object({
  name: z.string().min(2).max(50),
  email: z.string().email(),
  password: z.string().min(8, '密码至少8位')
    .regex(/[A-Z]/, '需要包含大写字母')
    .regex(/[0-9]/, '需要包含数字'),
})

// 注册
auth.post('/register', zValidator('json', registerSchema), async (c) => {
  const { name, email, password } = c.req.valid('json')

  // 检查邮箱是否已注册
  const existing = await c.env.DB
    .prepare('SELECT id FROM users WHERE email = ?')
    .bind(email).first()

  if (existing) {
    return c.json({ error: 'Email already registered' }, 409)
  }

  // 哈希密码(salt rounds = 12)
  const passwordHash = await bcrypt.hash(password, 12)

  // 创建用户
  const user = await c.env.DB
    .prepare('INSERT INTO users (name, email, password_hash) VALUES (?, ?, ?) RETURNING id, name, email')
    .bind(name, email, passwordHash).first<{ id: string; name: string; email: string }>()

  // 生成 Access Token(短期)
  const accessToken = await sign(
    { sub: user!.id, email: user!.email, exp: Math.floor(Date.now() / 1000) + 15 * 60 },  // 15分钟
    c.env.JWT_SECRET
  )

  // 生成 Refresh Token(长期)
  const refreshToken = await sign(
    { sub: user!.id, type: 'refresh', exp: Math.floor(Date.now() / 1000) + 7 * 24 * 3600 },  // 7天
    c.env.JWT_SECRET
  )

  return c.json({ user: { id: user!.id, name: user!.name, email: user!.email }, accessToken, refreshToken }, 201)
})

// 登录
auth.post('/login', zValidator('json', z.object({
  email: z.string().email(),
  password: z.string(),
})), async (c) => {
  const { email, password } = c.req.valid('json')

  const user = await c.env.DB
    .prepare('SELECT id, name, email, password_hash FROM users WHERE email = ?')
    .bind(email)
    .first<{ id: string; name: string; email: string; password_hash: string }>()

  if (!user || !(await bcrypt.compare(password, user.password_hash))) {
    // 故意不区分"用户不存在"和"密码错误",防止用户枚举
    return c.json({ error: 'Invalid email or password' }, 401)
  }

  const accessToken = await sign(
    { sub: user.id, email: user.email, exp: Math.floor(Date.now() / 1000) + 15 * 60 },
    c.env.JWT_SECRET
  )

  return c.json({ user: { id: user.id, name: user.name, email: user.email }, accessToken })
})

export default auth

JWT 验证中间件

// middleware/jwt-auth.ts
import { createMiddleware } from 'hono/factory'
import { verify } from 'hono/jwt'

export const jwtAuth = createMiddleware<{
  Bindings: { JWT_SECRET: string }
  Variables: { userId: string; userEmail: string }
}>(async (c, next) => {
  const authHeader = c.req.header('Authorization')

  if (!authHeader?.startsWith('Bearer ')) {
    return c.json({ error: 'Missing or invalid Authorization header' }, 401)
  }

  const token = authHeader.slice(7)

  try {
    const payload = await verify(token, c.env.JWT_SECRET)
    c.set('userId', payload.sub as string)
    c.set('userEmail', payload.email as string)
    await next()
  } catch {
    return c.json({ error: 'Invalid or expired token' }, 401)
  }
})

Rate Limiting(基于 KV)

// middleware/rate-limit.ts
import { createMiddleware } from 'hono/factory'

type RateLimitOptions = {
  windowSeconds: number
  max: number
  keyFn?: (c: any) => string
}

export const rateLimit = (opts: RateLimitOptions) =>
  createMiddleware<{ Bindings: { RATE_LIMIT_KV: KVNamespace } }>(async (c, next) => {
    const ip = c.req.header('CF-Connecting-IP') ?? 'unknown'
    const key = opts.keyFn ? opts.keyFn(c) : `rl:${c.req.path}:${ip}`

    const current = await c.env.RATE_LIMIT_KV.get(key)
    const count = current ? Number(current) : 0

    if (count >= opts.max) {
      c.header('Retry-After', String(opts.windowSeconds))
      c.header('X-RateLimit-Limit', String(opts.max))
      c.header('X-RateLimit-Remaining', '0')
      return c.json({ error: 'Too many requests, please try again later' }, 429)
    }

    // 原子计数(使用 expirationTtl 实现滑动窗口)
    await c.env.RATE_LIMIT_KV.put(key, String(count + 1), {
      expirationTtl: opts.windowSeconds,
    })

    c.header('X-RateLimit-Limit', String(opts.max))
    c.header('X-RateLimit-Remaining', String(opts.max - count - 1))
    await next()
  })

// 使用
app.use('/api/auth/*', rateLimit({ windowSeconds: 60, max: 10 }))  // 每分钟10次
app.use('/api/*', rateLimit({ windowSeconds: 60, max: 100 }))    // 每分钟100次

CSRF 防护

import { csrf } from 'hono/csrf'

// 内置 CSRF 防护(检查 Origin/Referer Header)
app.use(csrf({
  origin: ['https://myapp.com', 'https://staging.myapp.com'],
}))

// 对于纯 API(使用 Bearer Token),CSRF 威胁较低
// 对于使用 Cookie Session 的 Web 应用,CSRF 防护是必须的

安全 HTTP Headers

import { secureHeaders } from 'hono/secure-headers'

// 一键添加所有安全 Headers(类似 helmet)
app.use(secureHeaders())

// 等同于自动添加以下 Headers:
// X-Content-Type-Options: nosniff
// X-Frame-Options: DENY
// X-XSS-Protection: 0
// Referrer-Policy: no-referrer
// Permissions-Policy: ...(禁用不必要的浏览器权限)
// Strict-Transport-Security: max-age=15552000
// Content-Security-Policy: default-src 'self'

// 自定义 CSP
app.use(secureHeaders({
  contentSecurityPolicy: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'", "'unsafe-inline'"],  // 需要内联脚本时
    styleSrc: ["'self'", "fonts.googleapis.com"],
    imgSrc: ["'self'", "data:", "https:"],
  },
}))

OAuth2 集成(Clerk)

// 使用 Clerk 处理 OAuth2/OIDC(推荐的第三方方案)
bun add @clerk/backend
// middleware/clerk.ts
import { createClerkClient } from '@clerk/backend'
import { createMiddleware } from 'hono/factory'

export const clerkAuth = createMiddleware<{
  Bindings: { CLERK_SECRET_KEY: string }
  Variables: { userId: string | null }
}>(async (c, next) => {
  const clerk = createClerkClient({ secretKey: c.env.CLERK_SECRET_KEY })

  const token = c.req.header('Authorization')?.replace('Bearer ', '')

  if (token) {
    try {
      const session = await clerk.verifyToken(token)
      c.set('userId', session.sub)
    } catch {
      c.set('userId', null)
    }
  } else {
    c.set('userId', null)
  }

  await next()
})

安全最佳实践:(1) 永远不要在 JWT payload 中存储敏感信息(密码哈希、信用卡号),payload 是 Base64 编码,任何人都可以解码;(2) Access Token 有效期控制在 15 分钟以内;(3) 密码必须使用 bcrypt/argon2 哈希,绝不能明文存储;(4) 所有登录接口必须加 Rate Limiting;(5) 错误消息不要区分"用户不存在"和"密码错误",防止用户枚举攻击。