API安全与认证实战:从JWT到OAuth2的完整技术路线

API安全与认证实战:从JWT到OAuth2的完整技术路线

API安全的三个核心层次

独立开发者的产品API,一旦上线就会面临安全威胁。不是"我的产品小,没人攻击我"——很多攻击是自动化的扫描工具(Bot)在扫"有哪些API有常见漏洞",然后批量利用。

API安全分为三个层次,每个层次对应不同的威胁和防御策略:

L1:认证(Authentication)—— 你是谁?
确保"来访问API的请求,确实来自你声称的那个用户"。如果没有认证,任何人都能调用你的API(包括删除数据的接口)。

L2:授权(Authorization)—— 你能做什么?
即使确认了用户身份,还需要检查"这个用户是否有权限执行这个操作"。比如:用户A不能删除用户B的文章。

L3:防护(Protection)—— 如何防止滥用?
即使认证和授权都正确,API仍然可能被滥用(如某个用户用脚本高频调用你的API,导致服务不可用)。需要速率限制、输入验证、HTTPS等防护措施。

L1实战:JWT认证的正确实现

JWT(JSON Web Token)是目前最流行的API认证方案。但"用JWT"和"安全地用JWT"之间有很大差距。

JWT的基础流程:

  1. 用户登录(发送邮箱+密码)
  2. 服务器验证密码正确,生成一个JWT(包含用户ID、过期时间,用密钥签名)
  3. 客户端存储JWT(通常在localStorage或Cookie)
  4. 后续API请求,客户端在Authorization头里带上JWT
  5. 服务器验证JWT的签名和过期时间,如果有效,从JWT里解析出用户ID,执行请求

常见的安全陷阱与正确实现:

陷阱一:把JWT存在localStorage里
localStorage的缺点是"任何在你的域名下运行的JavaScript代码都能读取它"。如果你引入了有XSS漏洞的第三方脚本,攻击者的代码能偷走JWT。

正确做法:用HttpOnlySecureSameSite=Strict的Cookie存储JWT。

// 服务器设置Cookie的响应 res.cookie('token', jwtToken, { httpOnly: true, // JavaScript不能读取这个Cookie secure: true, // 只在HTTPS下发送 sameSite: 'strict', // 只在同站点请求里发送 maxAge: 7 * 24 * 60 * 60 * 1000 // 7天过期 });

陷阱二:JWT不过期,或过期时间太长
如果你设了expiresIn: '1y'的JWT,一旦这个JWT被偷走,攻击者能在整整一年内冒充这个用户。

正确做法:用"短期Access Token + 可刷新的Refresh Token"方案。

  • Access Token:过期时间短(如15分钟),存在内存里
  • Refresh Token:过期时间长(如7天),存在HttpOnlyCookie里
  • Access Token过期后,客户端用Refresh Token去换一个新的Access Token

陷阱三:JWT的签名密钥不够强
JWT的签名密钥如果太简单(如my-secret),攻击者可以用"暴力破解"或"字典攻击"猜出密钥,然后自己生成合法的JWT(就能冒充任何用户)。

正确做法:用足够随机的、长的密钥。

# 生成一个32字节的随机密钥 openssl rand -hex 32 # 输出示例:a3f9c2d8e5b1a7f4c8d2e6b9a5f3c8d1e7b5a9c3f7d5e2b8a6c4f9d1e3b7

把这个密钥存在环境变量里(不要硬编码在代码里):

const JWT_SECRET = process.env.JWT_SECRET!; // 生成JWT const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '15m' }); // 验证JWT try { const payload = jwt.verify(token, JWT_SECRET); // payload.userId 就是用户ID } catch (error) { // Token无效或过期 throw new Error('Unauthorized'); }

L2实战:基于角色的访问控制(RBAC)

认证解决了"你是谁",授权解决"你能做什么"。对于独立开发者的产品,最常用的授权模型是RBAC(Role-Based Access Control,基于角色的访问控制)

核心概念:

  • 角色(Role):如admin(管理员)、user(普通用户)、guest(访客)
  • 权限(Permission):如post:createpost:deleteuser:manage
  • 映射:哪个角色拥有哪些权限

数据库设计:

-- 用户表 CREATE TABLE users ( id SERIAL PRIMARY KEY, email TEXT UNIQUE, role TEXT DEFAULT 'user' -- 'admin' 或 'user' ); -- 权限表(可选,如果你需要很细粒度的权限控制) CREATE TABLE permissions ( id SERIAL PRIMARY KEY, role TEXT, resource TEXT, -- 如 'post' action TEXT -- 如 'create', 'delete' );

代码实现(Express.js中间件):

// 检查用户是否登录的中间件 function authenticate(req, res, next) { const token = req.cookies['token']; if (!token) return res.status(401).json({ error: 'Unauthorized' }); try { const payload = jwt.verify(token, process.env.JWT_SECRET!); req.user = { id: payload.userId, role: payload.role }; next(); } catch { return res.status(401).json({ error: 'Token invalid or expired' }); } } // 检查用户是否有权限的中间件(工厂函数模式) function authorize(requiredRole: string) { return (req, res, next) => { if (req.user.role !== requiredRole && req.user.role !== 'admin') { return res.status(403).json({ error: 'Forbidden' }); } next(); }; } // 使用方式 app.post('/api/posts', authenticate, (req, res) => { // 任何登录用户都能创建文章 }); app.delete('/api/posts/:id', authenticate, authorize('admin'), (req, res) => { // 只有admin能删除文章 });

更细粒度的授权:资源级权限

RBAC解决了"角色级别的权限"。但有时候你需要"资源级别的权限"——比如"用户A只能删除自己的文章,不能删除别人的文章"。

这种场景下,在具体的API端点里做检查:

app.delete('/api/posts/:id', authenticate, async (req, res) => { const post = await prisma.post.findUnique({ where: { id: req.params.id } }); if (!post) return res.status(404).json({ error: 'Not found' }); // 检查:这个文章是不是当前用户的? if (post.authorId !== req.user.id && req.user.role !== 'admin') { return res.status(403).json({ error: 'Forbidden' }); } await prisma.post.delete({ where: { id: req.params.id } }); res.json({ success: true }); });

L3实战:速率限制与输入验证

即使认证和授权都正确,API还需要防护"滥用"和"恶意输入"。

速率限制(Rate Limiting):

防止"某个用户用脚本高频调用你的API"(如1秒内发1000个请求),导致服务不可用或API成本暴涨(如果你的API调用了付费的外部服务)。

实现方案:用express-rate-limit库(内存存储)或redis(多服务器共享速率限制状态)。

import rateLimit from 'express-rate-limit'; // 全局速率限制:每个IP地址,15分钟内最多1000次请求 const globalLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15分钟 max: 1000, message: { error: 'Too many requests, please try again later.' } }); // 登录接口的更严格限制:每个IP,1小时内最多5次失败尝试 const loginLimiter = rateLimit({ windowMs: 60 * 60 * 1000, // 1小时 max: 5, message: { error: 'Too many login attempts, please try again in 1 hour.' } }); app.use(globalLimiter); app.post('/api/login', loginLimiter, async (req, res) => { // ... });

输入验证(Input Validation):

防止"用户传入恶意输入"导致的安全漏洞。如:

  • SQL注入:用户输入'; DROP TABLE users; --,如果你的代码是直接字符串拼接SQL,数据库可能被删
  • XSS:用户输入<script>alert('XSS')</script>,如果这段文字被其他用户看到(如评论内容),脚本会执行
  • 参数污染:用户传入意外的参数(如?isAdmin=true),如果你的代码直接从查询参数里读这个值,可能绕过授权

正确的输入验证方式:用Schema验证库(如Zod)

import { z } from 'zod'; // 定义"用户注册"接口的预期输入格式 const registerSchema = z.object({ email: z.string().email(), // 必须是有效邮箱格式 password: z.string().min(8), // 至少8位 username: z.string().min(3).max(20).regex(/^[a-zA-Z0-9_]+$/) // 只允许字母数字下划线 }); app.post('/api/register', async (req, res) => { try { // 验证输入 const validatedData = registerSchema.parse(req.body); // 现在可以安全地使用 validatedData.email, validatedData.password... const user = await createUser(validatedData); res.json(user); } catch (error) { if (error instanceof z.ZodError) { return res.status(400).json({ errors: error.errors }); } throw error; } });

关键原则:永远不要相信用户输入。所有输入都要验证、清理、参数化查询(防止SQL注入)。

OAuth2与第三方登录集成

最后谈一个常见需求:让用户用GitHub/Google/微信登录你的产品

这需要用OAuth2协议。核心流程是:

  1. 用户点击"用GitHub登录"按钮
  2. 跳转到GitHub的授权页面(用户在GitHub上确认"是否允许这个应用访问我的基本信息")
  3. 用户确认后,GitHub跳回你的产品(带上一个code
  4. 你的后端用code去换access_token
  5. access_token去GitHub API拿用户基本信息(邮箱、用户名)
  6. 根据邮箱找到或创建你产品的用户账号,生成JWT,登录完成

实战集成(以GitHub OAuth为例):

步骤一:在GitHub上注册OAuth应用

  • 登录GitHub → Settings → Developer settings → OAuth Apps → Register a new application
  • 填写Authorization callback URL(如https://yourproduct.com/api/auth/github/callback
  • 获得Client IDClient Secret

步骤二:后端实现OAuth流程

// 1. 重定向用户到GitHub授权页面 app.get('/api/auth/github', (req, res) => { const githubAuthURL = `https://github.com/login/oauth/authorize?client_id=${process.env.GITHUB_CLIENT_ID}&redirect_uri=${encodeURIComponent(process.env.GITHUB_CALLBACK_URL!)}&scope=user:email`; res.redirect(githubAuthURL); }); // 2. GitHub回调接口 app.get('/api/auth/github/callback', async (req, res) => { const { code } = req.query; // 2.1 用code换access_token const tokenResponse = await fetch('https://github.com/login/oauth/access_token', { method: 'POST', body: JSON.stringify({ client_id: process.env.GITHUB_CLIENT_ID, client_secret: process.env.GITHUB_CLIENT_SECRET, code }), headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' } }); const { access_token } = await tokenResponse.json(); // 2.2 用access_token拿用户信息 const userResponse = await fetch('https://api.github.com/user', { headers: { 'Authorization': `Bearer ${access_token}` } }); const githubUser = await userResponse.json(); // 2.3 根据邮箱找或创建用户 let user = await prisma.user.findUnique({ where: { email: githubUser.email } }); if (!user) { user = await prisma.user.create({ data: { email: githubUser.email, username: githubUser.login, provider: 'github' } }); } // 2.4 生成JWT,设置Cookie,重定向到首页 const token = jwt.sign({ userId: user.id, role: user.role }, process.env.JWT_SECRET!, { expiresIn: '7d' }); res.cookie('token', token, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000 }); res.redirect('/'); });

安全注意事项:

  • Client Secret必须存在环境变量里,不能暴露在前端代码
  • redirect_uri必须精确匹配,防止"OAuth重定向绕过"攻击
  • 拿到用户信息后,验证"这个邮箱是不是真的属于这个用户"(GitHub会返回已验证的邮箱,但如果你用其他OAuth提供商,需要验证邮箱归属)

结论:API安全不是"接个JWT就完了"。它需要你理解认证、授权、防护三个层次,并在每个层次上避免常见的安全陷阱。独立开发者不需要在早期就做到银行级别的安全,但至少需要:用HttpOnly Cookie存JWT、用Zod验证所有输入、给关键接口加速率限制——这三项是最低要求。