认证 vs 授权
- 认证(Authentication) 你是谁? 验证用户身份。常见方式:用户名+密码、OAuth 社交登录、API Key、短信验证码。
- 授权(Authorization) 你能做什么? 验证已认证用户是否有权限访问特定资源。常见方式:RBAC(基于角色)、ABAC(基于属性)。
- JWT JSON Web Token,一种自包含的令牌格式。由三部分组成:Header(算法)+ Payload(数据)+ Signature(签名),用点(.)分隔,Base64 编码。
- Refresh Token 刷新令牌。Access Token 短期有效(15分钟~1小时)降低泄露风险;Refresh Token 长期有效(7~30天),用于无感刷新 Access Token。
密码安全:bcrypt vs Argon2
密码绝对不能明文存储,必须使用单向哈希函数。普通哈希(MD5、SHA256)不够——它们太快,GPU 可以暴力破解。密码哈希需要专门的慢哈希算法:
| 算法 | 类型 | 推荐程度 | 特点 |
|---|---|---|---|
| bcrypt | CPU 密集 | ✅ 成熟稳定 | 工作因子可调,最广泛使用 |
| Argon2id | CPU+内存密集 | ✅✅ 推荐首选 | 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 等于裸奔。