Chapter 06

认证系统实现

JWT、Refresh Token、密码安全、OAuth2.0——构建生产级身份验证系统

认证 vs 授权

密码安全:bcrypt vs Argon2

密码绝对不能明文存储,必须使用单向哈希函数。普通哈希(MD5、SHA256)不够——它们太快,GPU 可以暴力破解。密码哈希需要专门的慢哈希算法:

算法类型推荐程度特点
bcryptCPU 密集✅ 成熟稳定工作因子可调,最广泛使用
Argon2idCPU+内存密集✅✅ 推荐首选OWASP 推荐,抗 GPU/FPGA 攻击
scrypt内存密集✅ 可用Node.js 内置 crypto.scrypt
MD5/SHA256快速哈希❌ 禁用不适合密码哈希
bun add bcryptjs
bun add -d @types/bcryptjs

# 或使用 Argon2(推荐)
bun add argon2
// lib/password.ts — 密码哈希工具
import argon2 from 'argon2';

export async function hashPassword(password: string): Promise<string> {
  return argon2.hash(password, {
    type: argon2.argon2id,
    memoryCost: 65536,  // 64 MB 内存
    timeCost: 3,         // 迭代次数
    parallelism: 1,
  });
}

export async function verifyPassword(hash: string, password: string): Promise<boolean> {
  try {
    return await argon2.verify(hash, password);
  } catch {
    return false;
  }
}

JWT 实现

bun add jose  # 推荐:支持所有运行时(包括 CF Workers)
// lib/jwt.ts
import { SignJWT, jwtVerify, type JWTPayload } from 'jose';

const ACCESS_SECRET = new TextEncoder().encode(process.env.JWT_ACCESS_SECRET!);
const REFRESH_SECRET = new TextEncoder().encode(process.env.JWT_REFRESH_SECRET!);

type TokenPayload = { userId: string; role: string };

// 生成 Access Token(短效,15分钟)
export async function signAccessToken(payload: TokenPayload) {
  return new SignJWT({ ...payload })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('15m')
    .sign(ACCESS_SECRET);
}

// 生成 Refresh Token(长效,7天)
export async function signRefreshToken(userId: string) {
  return new SignJWT({ userId, tokenType: 'refresh' })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setJWTClaimsSet({ jti: crypto.randomUUID() }) // 唯一 ID,用于撤销
    .setExpirationTime('7d')
    .sign(REFRESH_SECRET);
}

// 验证 Access Token
export async function verifyAccessToken(token: string): Promise<TokenPayload> {
  const { payload } = await jwtVerify(token, ACCESS_SECRET);
  return payload as JWTPayload & TokenPayload;
}

export async function verifyRefreshToken(token: string) {
  const { payload } = await jwtVerify(token, REFRESH_SECRET);
  return payload;
}

完整认证 API(Hono)

// routes/auth.ts
import { Hono } from 'hono';
import { getCookie, setCookie, deleteCookie } from 'hono/cookie';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { prisma } from '../lib/prisma';
import { hashPassword, verifyPassword } from '../lib/password';
import { signAccessToken, signRefreshToken, verifyRefreshToken } from '../lib/jwt';

const auth = new Hono();

const registerSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8).regex(/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
    '密码需包含大小写字母和数字'),
  name: z.string().min(2),
});

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

  const existing = await prisma.user.findUnique({ where: { email } });
  if (existing) return c.json({ error: '邮箱已注册' }, 409);

  const hashedPwd = await hashPassword(password);
  const user = await prisma.user.create({
    data: { email, password: hashedPwd, name },
    select: { id: true, email: true, name: true },
  });

  return c.json({ user }, 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 prisma.user.findUnique({ where: { email } });
  // 故意不区分"用户不存在"和"密码错误",防止用户枚举攻击
  if (!user || !await verifyPassword(user.password, password)) {
    return c.json({ error: '邮箱或密码错误' }, 401);
  }

  const [accessToken, refreshToken] = await Promise.all([
    signAccessToken({ userId: user.id, role: user.role }),
    signRefreshToken(user.id),
  ]);

  // Refresh Token 存入 HTTP-only Cookie(防 XSS 盗取)
  setCookie(c, 'refresh_token', refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'Strict',
    maxAge: 7 * 24 * 60 * 60,  // 7天,秒为单位
    path: '/auth/refresh',     // 只在刷新端点发送
  });

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

// 刷新 Token
auth.post('/refresh', async (c) => {
  const refreshToken = getCookie(c, 'refresh_token');
  if (!refreshToken) return c.json({ error: 'No refresh token' }, 401);

  try {
    const payload = await verifyRefreshToken(refreshToken);
    const user = await prisma.user.findUnique({
      where: { id: payload.userId as string }
    });
    if (!user) throw new Error('User not found');

    const newAccessToken = await signAccessToken({ userId: user.id, role: user.role });
    return c.json({ accessToken: newAccessToken });
  } catch {
    deleteCookie(c, 'refresh_token');
    return c.json({ error: 'Invalid refresh token' }, 401);
  }
});

// 登出
auth.post('/logout', (c) => {
  deleteCookie(c, 'refresh_token');
  return c.json({ ok: true });
});

export { auth as authRouter };

认证中间件

// middleware/auth.ts
import { createMiddleware } from 'hono/factory';
import { verifyAccessToken } from '../lib/jwt';

type AuthEnv = {
  Variables: { userId: string; userRole: string };
};

export const requireAuth = createMiddleware<AuthEnv>(async (c, next) => {
  const authHeader = c.req.header('Authorization');
  if (!authHeader?.startsWith('Bearer ')) {
    return c.json({ error: '需要登录' }, 401);
  }

  const token = authHeader.slice(7);
  try {
    const payload = await verifyAccessToken(token);
    c.set('userId', payload.userId);
    c.set('userRole', payload.role);
    await next();
  } catch {
    return c.json({ error: 'Token 无效或已过期' }, 401);
  }
});

export const requireRole = (...roles: string[]) =>
  createMiddleware<AuthEnv>(async (c, next) => {
    const role = c.get('userRole');
    if (!roles.includes(role)) {
      return c.json({ error: '权限不足' }, 403);
    }
    await next();
  });

OAuth2.0 社交登录(GitHub 为例)

OAuth2.0 的核心流程(授权码模式):

1. 用户点击"GitHub 登录" → 跳转到 GitHub 授权页
2. 用户同意授权 → GitHub 回调我们的 /auth/github/callback?code=xxx
3. 后端用 code 换取 access_token(服务器到服务器,不暴露给前端)
4. 用 access_token 获取用户信息(id, email, name, avatar)
5. 数据库 upsert 用户,生成我们自己的 JWT 返回给前端
bun add arctic  # Arctic:专为 OAuth 设计的轻量库,支持 GitHub/Google/Discord 等
import { GitHub, generateState } from 'arctic';
import { getCookie, setCookie } from 'hono/cookie';

const github = new GitHub(
  process.env.GITHUB_CLIENT_ID!,
  process.env.GITHUB_CLIENT_SECRET!,
  { redirectURI: 'http://localhost:3000/auth/github/callback' }
);

// 步骤1:重定向到 GitHub
app.get('/auth/github', (c) => {
  const state = generateState();
  const url = github.createAuthorizationURL(state, ['user:email']);

  setCookie(c, 'oauth_state', state, {
    httpOnly: true, maxAge: 600, secure: true, sameSite: 'Lax',
  });

  return c.redirect(url.toString());
});

// 步骤2:处理回调
app.get('/auth/github/callback', async (c) => {
  const { code, state } = c.req.query();
  const storedState = getCookie(c, 'oauth_state');

  if (!code || state !== storedState) {
    return c.json({ error: 'Invalid state' }, 400);
  }

  // 用 code 换 token
  const tokens = await github.validateAuthorizationCode(code);

  // 获取 GitHub 用户信息
  const githubUser = await fetch('https://api.github.com/user', {
    headers: { Authorization: `Bearer ${tokens.accessToken()}` }
  }).then(r => r.json());

  // upsert 用户到数据库
  const user = await prisma.user.upsert({
    where: { githubId: String(githubUser.id) },
    update: { name: githubUser.name, avatar: githubUser.avatar_url },
    create: {
      githubId: String(githubUser.id),
      email: githubUser.email ?? `github_${githubUser.id}@noemail.local`,
      name: githubUser.name ?? githubUser.login,
      avatar: githubUser.avatar_url,
    },
  });

  const accessToken = await signAccessToken({ userId: user.id, role: user.role });

  // 重定向到前端,携带 token
  return c.redirect(`/app?token=${accessToken}`);
});

安全最佳实践:① JWT Secret 必须是随机长字符串(openssl rand -hex 32),不能是"mysecret"这类弱密钥;② Refresh Token 应该存入数据库(可撤销),不要只靠过期时间;③ 实现速率限制(Rate Limiting)防止暴力破解;④ HTTPS 是必须的,HTTP 传输 JWT 等于裸奔。