保护Express API:认证、数据验证、速率限制与监控。
API安全的实用Express快速指南:Passport与JWT认证、授权模型、AES-GCM加密、数据验证、速率限制及日志记录。
您公开的每一个 API 都是通往系统内部的入口,攻击者对这些入口的探测往往比大多数团队进行的测试更为系统化。在系统上线后才加装的安保措施往往会留下漏洞:没有防护的接口、基于原始输入构建的查询语句、能够轻易接受海量尝试的登录端点。本指南将介绍值得了解的标准、出现频率最高的威胁,以及使用 Node.js 和 Express 实现的六层具体防御措施,帮助您对现有 API 进行审计,或从一开始就构建具备防护功能的新 API。
请将以下内容视为一份检查清单,在整个开发生命周期中反复查阅:在发布之前、打补丁之后,以及任何依赖项或接口发生变化时。定期执行这些检查,才能在漏洞还较小时就发现它们。
为何 API 需要专门的安全关注
现代产品越来越多地由应用程序接口构成。团队不再自行开发所有功能,而是通过明确定义的接口整合支付、身份验证、消息传递和数据服务,如今许多公司都推出以API为核心的产品。一项市场研究预计2026年API经济的规模约为200亿美元(报告摘要);无论具体数字如何,这种依赖关系确实存在且日益加剧。
这种依赖是双向的。API为合法用户提供即用且可重复使用的功能,但同时也为攻击者提供了有文档记载、便于机器利用的入侵入口。行业调查一直显示,大量安全漏洞都与API有关;Traceable的2023年报告指出74%的数据泄露事件都是由API引发的。
API通常直接涉及企业所拥有的最敏感数据:包含个人信息的身份认证平台、财务记录以及内部工作流程。未经授权的访问可能导致数据损坏、服务被滥用、经济损失、客户信任度下降,甚至面临数据保护法规的处罚。鉴于风险如此之高,安全性应与系统正常运行时间一同被纳入服务级别协议中,这是每个产品团队的责任,而不仅仅是专门的安全团队。合理的起点是采用行业已达成共识的标准。
值得了解的标准与框架
API安全标准是正式的规范、协议和指南,应用于整个软件开发生命周期,以确保保护措施的一致性而非临时应付。您最常遇到的标准包括:
- OWASP API安全十大风险:由开放网络应用安全项目制定的最关键API风险排名列表。它涵盖了对象级授权失效、服务器端请求伪造以及资源消耗无限制等问题,是进行威胁评估的最佳起点。
- OAuth 2.0和2.1:一种委托授权框架。客户端可获得具有特定权限范围的访问令牌,并以此代表用户执行操作,而无需处理该用户的密码;刷新令牌则允许客户端在无需再次询问用户的情况下获取新的访问令牌。
- OpenID Connect(OIDC):建立在OAuth之上的身份认证层。它规范了身份令牌及其验证方式,从而使单点登录以及从身份提供方获取用户信息的功能实现互操作。
这些框架能为您提供坚实的基础,但并非详尽无遗的清单。
需要防范的威胁
实际API中最常见的漏洞包括:
- 认证机制缺陷:身份验证措施薄弱或缺失,会话管理不善,使得攻击者能够窃取 Cookie 或令牌,并将其重复利用来访问您的服务。
- 对象级授权缺陷:API 仅检查用户是否已登录,而不验证其是否有权限操作特定记录,因此通过修改 URL 中的 ID 就可能泄露其他人的数据或内部工作流程。
- SQL 注入:攻击者控制的输入内容会被拼接进查询语句中,随后由数据库执行。几乎所有的数据库客户端都提供了能够安全传递值的参数机制。
- 命令注入:请求中不可信的输入会传递到系统shell或命令处理器,从而使攻击者能够以您的服务器进程权限运行任意命令。
针对每一种情况都有相应的编程实践。本指南的其余部分将从六个层面进行阐述,全程以 JavaScript 和 Express 作为示例。
1. 身份验证:确认调用者的身份
每个受保护的 API 都应在执行任何重要操作之前要求客户端证明其身份,验证方式可以是用户名和密码、API 密钥或签名令牌。身份验证方法大致可分为五类:用户名和密码、多因素认证、基于令牌的认证、基于证书的认证以及生物特征识别。
一个常见的混淆点在于JWT实际上替代了什么。传统的服务器会话将状态存储在服务器上,并依赖浏览器cookie来传递会话ID。JWT消除了每次请求时都需要在服务器端进行查找的必要,但它本身并不负责验证凭证:在生成令牌之前仍需有人检查密码。正因如此,混合流程更为适用——用户通过邮箱和密码登录,服务器在验证成功后生成JWT,之后的每次请求只需传递该令牌即可。凭证检查和每次请求的认证变成了独立的两项工作,密码也不再需要随每个请求一同传输。
在Express中,Passport库通过可插拔的策略支持这两部分功能。其设置分为三个步骤。
第一步:注册本地策略和JWT策略
本地策略在登录时运行一次,负责通过电子邮件查找用户,并将输入的密码与存储的哈希值进行比对。JWT策略则在每个受保护的请求中运行:它从Authorization: Bearer头部提取令牌,使用JWT_SECRET验证签名,并解析载荷中提到的用户信息。通过设置session: false来导出预配置的authenticateJWT中间件,可以明确体现其无状态特性。
const passport = require("passport");
const LocalStrategy = require("passport-local").Strategy;
const { Strategy: JwtStrategy, ExtractJwt } = require("passport-jwt");
// Local Strategy: Verify username and password during login.
passport.use(
new LocalStrategy(
{ usernameField: "email", passwordField: "password" },
async (email, password, done) => {
// Find the user and compare the hashed password.
// If valid, return the user.
}
)
);
// JWT Strategy: Verify the token on protected requests.
passport.use(
new JwtStrategy(
{
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET,
},
async (payload, done) => {
// Find the user referenced in the token.
}
)
);
// Middleware
const authenticateJWT = passport.authenticate("jwt", { session: false, });
module.exports = { passport, authenticateJWT, };
验证回调函数被放在此处作为注释,而真正的安全处理工作就在这些回调中完成。在密码比对时应使用bcrypt或Argon2这类带有盐值的慢速哈希算法,无论电子邮件是否存在还是密码是否正确,都返回相同的通用错误信息,这样就无法通过该接口发现存在哪些账户。
步骤2:登录时进行身份验证并生成令牌
登录处理程序通过自定义回调来调用本地策略。如果发生错误,会由Express的错误处理机制处理;若用户不存在,则返回401状态码;而当匹配成功时,就会根据用户的ID和角色生成一个令牌,其有效期取自JWT_EXPIRES_IN设置的值,若该值未指定则默认为两小时。
const jwt = require("jsonwebtoken");
const passport = require("passport");
const login = (req, res, next) => {
passport.authenticate("local", { session: false }, (err, user, info) => {
if (err) return next(err);
if (!user) return res.status(401).json({ message: info.message, });
// Issue a signed JWT after successful authentication.
const token = jwt.sign(
{ id: user.id, role: user.role,},
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN || "2h",}
);
return res.status(200).json({ message: "Login successful.", token, user,});
})(req, res, next);
};
有两点值得注意。较短的过期时间限制了被盗令牌的有效时长;如果会话需要更长时间的有效性,应将短访问令牌与刷新机制结合使用,比如我们在Node.js认证系统的刷新令牌策略中描述的那种方式。此外,响应会原样返回user对象。如果该对象是原始数据库记录,其中可能包含密码哈希值和内部字段,这正是之前所说的过度数据泄露问题。此时应仅返回ID、邮箱和角色等必要字段。
步骤3:保护受保护的路由
在导出了中间件之后,要保护某条路由,只需在路由定义中将authenticateJWT放在控制器之前即可。没有有效令牌的请求会在任何业务逻辑执行之前被拒绝。
const { Router } = require("express");
const authRouter = Router();
// Get auth middleware and sample prorected controller
const authController = require("../controllers/auth.controller");
const { authenticateJWT } = require("../middleware/authentication");
// Use JWT as a guard to protect certain routes
authRouter.get("/me", authenticateJWT, authController.me);
authRouter.patch("/password", authenticateJWT, authController.updatePassword);
如果更倾向于使用精简型的路由器,同样的保护机制也可以被加入控制器自身的中间件链中。无论采用哪种方式,都应将防护设置为路由器的默认设置,并刻意免除公共路由的防护,而非每次都要记得为单个路由添加保护机制。
2. 权限控制:决定调用者可执行的操作
身份验证回答的是“你是谁?”;而权限控制则回答“你被允许做什么?”。它通常在身份验证之后立即执行,根据访问规则对每个身份进行评估,从而决定是否允许请求通过。如果没有权限控制,任何已登录的用户都可能读取敏感数据或触发特权操作,这正是BOLA漏洞产生的原因。
三种模型可以满足大多数需求:
- 基于角色的访问控制(RBAC)是将权限分配给角色,再将角色分配给用户。一个博客API可能设有管理员、编辑和查看者等角色。由于它适用于结构稳定的工作职能与群体,因此在企业应用中十分常见。
- 基于属性的访问控制(ABAC)会依据策略,对用户、资源以及请求环境(部门、资源敏感度、时间、网络状况)的属性进行评估。它适合那些决策高度依赖上下文或频繁变化的API。
- 基于关系的访问控制(ReBAC)则是根据用户与特定资源之间的关系(如所有权或群组成员身份)来授予访问权限,通常通过关系图来验证这些关系。它非常适合文档共享或社交平台等协作类产品。
自行构建授权机制是了解其复杂细节的绝佳方式,但生产系统通常会将令牌的发行与验证工作委托给身份提供方。当 API 在 Microsoft Entra ID 等提供方处注册并配置为接受承载令牌时,敏感接口会在执行操作前验证每个令牌的权限范围和角色。无效的令牌或缺失的权限会导致 401 Unauthorized 错误。在 Express 中,受保护的路由如下所示:
app.get(
"/api/orders",
passport.authenticate("oauth-bearer", { session: false }),
(req, res) => {
res.json({ message: "Protected resource." });
}
);
请记住,经过验证的令牌仅能确定粗粒度的权限。诸如“该订单是否属于该用户?”之类的对象级检查仍需在处理程序或数据层中进行,因为没有任何身份提供方能够知道数据库中第 4812 行的数据归属谁。
对于相关协议,可依赖 OAuth 2.0、OpenID Connect 和 SAML 等行业标准。您可以选择自行实现这些流程,或将其委托给 Ping Identity、Okta、Microsoft Entra ID、AWS 或 IBM Security Verify 等身份提供商来处理。
3. 加密:保护传输中的数据与静态存储的数据
加密会将可读数据转换为密文,没有正确的密钥则无法解密。TLS 用于保护数据在传输过程中的安全;而静态加密则用于保护存储中的数据,包括数据库中的数据。敏感系统通常会同时使用这两种加密方式,因为如果没有加密,财务凭证等数据就可能被截获或从受损的存储系统中窃取。
各种主要方法都是在速度与密钥管理之间进行权衡:
- 对称加密使用一个共享密钥。它的速度非常快,且能很好地处理大量数据,因此非常适合用于静态数据及载荷加密,但双方必须安全地保管相同的密钥。
- 非对称加密使用公钥和私钥对,因此无需交换共享密钥。它的速度相对较慢,仅适用于小型数据量。
- 混合加密结合了这两种方式:非对称加密用于保护对称密钥,而对称密钥则用于保护大量数据。这样既能获得前者在密钥交换方面的优势,又能具备后者的快速处理能力。
对于对称加密,Node 的内置 crypto 模块支持 AES-256-GCM。下面的处理函数会序列化请求体,生成一个新的 12 字节初始化向量,对数据进行加密,然后以十六进制字符串的形式返回该初始化向量、GCM 认证标签以及密文。
const crypto = require("crypto");
const algorithm = "aes-256-gcm";
const key = Buffer.from(process.env.ENCRYPTION_KEY, "hex");
app.post("/api/orders", (req, res) => {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv(algorithm, key, iv);
const encrypted = Buffer.concat([
cipher.update(JSON.stringify(req.body), "utf8"),
cipher.final(),
]);
const payload = {
iv: iv.toString("hex"),
tag: cipher.getAuthTag().toString("hex"),
data: encrypted.toString("hex"),
};
// Store or transmit the encrypted payload
res.json(payload);
});
有几点确保了这种做法的正确性。密钥的长度必须恰好为32字节(在ENCRYPTION_KEY中表现为64个十六进制字符),且应来自密钥管理器而非源代码。对于同一密钥下的每次加密,初始化向量都必须唯一;使用GCM时重复使用IV会导致严重后果,因此该向量是按请求生成的。认证标签能让解密方检测到数据是否被篡改,因此必须与密文一起存储,并在解密时进行验证。在实际服务中,应持久化保存该数据或将其转发,而非像演示那样将其返回给调用方。
同一模块中也支持非对称加密。用公钥加密的数据只能用对应的私钥解密:
const crypto = require("crypto");
const encrypted = crypto.publicEncrypt(
publicKey,
Buffer.from("Sensitive API data")
);
由于RSA只能加密小于其密钥长度的数据,publicEncrypt适用于密码字段或对称密钥等较短的内容,而不适合加密整个文档。混合工作流正是为了解决这一限制:先生成一个临时的AES密钥,用该密钥加密数据内容,再使用接收方的RSA公钥加密这个AES密钥,最后将两者一起发送。接收方利用自己的私钥恢复AES密钥,进而解密数据内容。由于TLS已经实现了类似的加密流程,大多数API在应用代码中无需使用此方法,但在端到端加密场景中了解它是很有必要的。
4. 输入验证与净化
一旦 API 接收了客户端数据,就无法预测其中会包含什么内容。格式错误的请求体、SQL 代码片段以及脚本载荷,在被解析之前都看起来只是普通的字符串。有两种互补的技术可以解决这个问题:验证功能会拒绝那些违反结构或语义规则的输入;而净化功能则会在输入到达处理程序之前,将其转换为安全且标准化的形式。
首先强制指定内容类型
最简单的检查方式就是确认请求本身的格式。这个小型中间件工具使用 req.is() 方法来验证 Content-Type,若不符合要求则返回 415 Unsupported Media Type 错误响应。它可以在全局、特定路由器或单个接口上启用。
const requireContentType = (type) => (req, res, next) => {
if (!req.is(type)) {
return res.status(415).json({ error: "Unsupported Media Type", });
}
next();
};
app.post("/api/users", requireContentType("application/json"),
(req, res) => {
res.json({ message: "User created." });
}
);
验证请求体的结构与含义
在格式得到保证后,下一层会检查请求是否结构正确。使用 express-validator 可将相关规则放在专门的验证器模块中。该模块要求电子邮件在语法上有效,会执行异步的自定义检查以排除数据库中已存在的地址,并强制要求密码长度至少为八个字符。
const { body } = require("express-validator");
const { getUserEmail } = require("../db/queries");
const validateRegistration = [
body("email")
.isEmail()
.withMessage("Invalid email format")
.custom(async (value) => {
if (await getUserEmail(value)) {
throw new Error("Email is already in use");
}
return true;
}),
body("password")
.isLength({ min: 8 })
.withMessage("Password must be at least 8 characters long"),
];
module.exports = { validateRegistration }
随后将该验证器数组加入路由的中间件链中。在处理函数内部,validationResult(req) 会收集所有验证失败的情况,路由会返回包含完整错误列表的 400 状态码,而不会继续处理请求。
const { validationResult } = require("express-validator");
const { validateRegistration } = require("../validators/userValidator");
app.post("/api/register", validateRegistration, (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
res.json({ message: "Registration successful." });
});
验证后的净化处理
由于 Express 是按顺序处理中间件的,因此可以在验证之后立即进行净化操作。此处会去除名字的前后空格并对其进行 HTML 转义,同时还会对电子邮件地址进行标准化处理。
const sanitizeRegistration = [
body("firstName").trim().escape(),
body("email").normalizeEmail(),
];
app.post(
"/api/register",
validateRegistration,
sanitizeRegistration,
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
res.json({ message: "Registration successful." });
}
);
这里的顺序起着微妙的作用。验证器中的唯一性检查在normalizeEmail()之前执行,因此相同地址的不同拼写可能会绕过检查从而创建重复账户。在查询之前进行标准化处理,或是在数据库层面对标准化后的值强制实施唯一性约束,就能弥补这一缺陷。此外在使用escape()时也要谨慎:对输入进行HTML编码可以保护渲染该值的模板,但会改变存储的数据,而许多团队更倾向于存储原始值并在输出时再进行编码。如果您正在考虑不同的验证库,我们关于Zod与express-validator的对比文章介绍了它们之间的权衡。
对数据库使用参数化查询
绝不要通过拼接用户输入来构建SQL语句。像pg这样的数据库客户端以及Prisma这样的ORM工具都支持参数化查询,它们会分别发送查询文本和参数值,这样数据库就会始终将输入视为数据,而绝不会将其当作可执行的SQL语句。
使用pg时,只需创建一个连接池并将其导出供数据模块使用:
const { Pool } = require("pg");
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
module.exports = pool;
查询时会使用带编号的占位符($1、$2),参数值则作为单独的数组提供。即便email字段中包含引号 followed by DROP TABLE语句,它也会被存储为普通的字符串。
app.post("/api/users", async (req, res) => {
const { email, name } = req.body;
await pool.query(
"INSERT INTO users (email, name) VALUES ($1, $2)",
[email, name]
);
res.status(201).json({ message: "User created." });
});
这些组件共同构成了分层处理流程。Express-validator允许你将规则封装为可重复使用的单元,并以中间件的形式运行,同时报告每一次验证失败的情况;而参数化机制则确保即使有数据绕过了验证,也无法篡改你的查询语句。
5. 请求频率限制与节流
请求频率限制规定了客户端在特定时间窗口内可发起的请求数量。它能够有效遏制暴力破解和拒绝服务攻击,同时防止某个高负载用户占用过多资源而影响其他用户。
限制可以从不同维度实施:
- 按客户端:根据 API 密钥或 IP 地址来统计请求数。当达到上限时,该客户端需等待时间窗口重置或申请更高配额,通常需要付费才能实现。
- 按地理位置或时间:限制会根据地区或时间窗口有所不同,例如允许客户所在地区的流量更多,而对可疑流量来源地则设置更严格的限制。
存在多种算法(固定窗口、滑动窗口、令牌桶),初学者无需自行实现这些算法即可开始使用。express-rate-limit中间件默认会按IP地址统计请求次数。下面的示例为/api下的所有请求设置了15分钟内最多100次请求的总体限制,而对登录操作则设置了更为严格的5分钟内仅5次尝试的限制,并为被拒绝的请求提供了自定义提示信息。
const rateLimit = require("express-rate-limit");
// Apply to all API routes
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
});
// Apply stricter limits to authentication endpoints
const loginLimiter = rateLimit({
windowMs: 5 * 60 * 1000, // 5 minutes
max: 5,
message: "Too many login attempts. Please try again later.",
});
app.use("/api", apiLimiter);
app.post("/api/login", loginLimiter, (req, res) => {
res.json({ message: "Login successful." });
});
公共 API 通常会为每个使用者分配一个密钥,通过该密钥进行限制比通过 IP 地址限制更为公平,因为许多用户可以通过企业代理共享同一个地址。自定义的 keyGenerator 会读取 X-API-Key 标头,并将其作为计数器的标识。
const rateLimit = require("express-rate-limit");
const apiKeyLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 1000,
keyGenerator: (req) => req.get("X-API-Key"),
});
app.use("/api", apiKeyLimiter);
按照当前实现,任何未包含该标头的请求都会生成相同的 undefined 密钥,从而共享同一个处理池。实际上应在请求处理的早期阶段就拒绝没有密钥的请求,或转而使用 IP 地址作为限制依据。
限制高资源消耗的接口调用频率
流量限制用于控制请求的接收速度,以避免突发的高负载压垮服务。接下来的配置仍使用相同的中间件,但设置了更短的窗口期:整个API每秒最多接收10个请求,且每个客户端每两秒仅允许发起一次搜索请求,因为搜索是资源消耗最大的接口。
const rateLimit = require("express-rate-limit");
// Throttle all API requests
const apiThrottle = rateLimit({
windowMs: 1000, // 1 second
max: 10, // Allow up to 10 requests per second
});
// Apply a stricter throttle to resource-intensive endpoints
const searchThrottle = rateLimit({
windowMs: 2000, // 2 seconds
max: 1, // Allow 1 request every 2 seconds
message: "Please wait before sending another search request.",
});
app.use("/api", apiThrottle);
app.get("/api/search", searchThrottle, (req, res) => {
res.json({ results: [] });
});
严格来说,这仍然属于窗口时间较短的速率限制:多余的请求会收到429响应而被拒绝,而非被延迟处理。如果您希望实现真正的限流机制,在拒绝请求之前先降低客户端请求速度,可以使用如express-slow-down这样的配套包来实现渐进式延迟。另外需要注意的是,默认的内存存储是按进程计数的,因此在具有多个实例的负载均衡器后,需要使用Redis这样的共享存储才能保持限流设置。近期版本的express-rate-limit还将max选项命名为limit,请查阅您所安装版本的文档以获取详细信息。如需轻量级的TypeScript替代方案,可参阅我们关于Express专用最小化速率限制器的文章。
6. 日志记录、监控与故障检测
你无法对看不见的攻击作出响应。日志记录会保存请求与响应及其元数据、上下文、时间戳和错误代码,这样你就能进行故障排查、审计并了解实际使用情况。监控则能实时观察活动状况,追踪延迟、错误率和吞吐量等指标,并发现可能表明存在滥用行为、未达到服务级别目标或漏洞正被利用的异常情况。
主要方法及其优缺点如下:
- 使用摩根(Morgan)这类中间件进行请求日志记录,可以低成本捕获所有传入的HTTP请求,但无法反映系统健康状况。
这些插件都可以接入 Express。接下来是四种常见的构建模块。
使用 Morgan 进行请求日志记录
以 combined 格式注册 Morgan 时,会为每个请求生成类似 Apache 的日志行,其中包含方法、路径、状态码、响应大小以及用户代理信息。
const express = require("express");
const morgan = require("morgan");
const app = express();
// Log every incoming request
app.use(morgan("combined"));
app.get("/api/users", (req, res) => {
res.json({ message: "Users retrieved successfully." });
});
使用 Winston 进行结构化应用日志记录
Winston 以结构化对象的形式记录事件。在创建订单时记录用户 ID 和订单 ID,可以生成日后可查询的审计轨迹。
const winston = require("winston");
const logger = winston.createLogger({
transports: [
new winston.transports.Console(),
],
});
app.post("/api/orders", (req, res) => {
logger.info("Order created", {
userId: req.user.id,
orderId: req.body.id,
});
res.status(201).json({ message: "Order created." });
});
需注意日志中记录的内容。用户 ID 是安全的;而密码、令牌、完整的卡号以及整个请求体则不可记录,因为日志往往是敏感数据泄露的常见途径。
集中式错误日志记录
这种Express错误处理中间件通过其四个参数来识别,能够捕获任意路由中的错误。它会将包含路径和方法信息的日志记录下来,并返回通用的500状态码,从而确保堆栈跟踪信息及内部细节不会传递给客户端。
app.use((err, req, res, next) => {
/* Logger is built as an independent module or class */
logger.error(err.message, {
path: req.originalUrl,
method: req.method,
});
res.status(500).json({
error: "Internal Server Error",
});
});
为Prometheus提供指标
prom-client库会收集Node.js进程的默认指标,并通过/metrics接口将这些指标暴露出来,以便Prometheus进行抓取。
const client = require("prom-client");
client.collectDefaultMetrics();
app.get("/metrics", async (req, res) => {
res.set("Content-Type", client.register.contentType);
res.end(await client.register.metrics());
});
该接口会暴露有关您服务的内部细节,因此应将其限制在监控网络范围内或设置身份验证,而不要公开访问。
具有明确设计理念的框架在此类场景中能发挥重要作用。NestJS 内置了日志记录功能,并提供了便于为每个接口添加日志和性能监控的架构,而像 Express 这样设计理念不明确的框架则需手动添加日志功能,通常是通过在响应发送前运行的中间件来实现。事故检测正是基于这些日志来进行的:它会针对异常模式,比如 401 响应数量激增或速率限制被拒绝等情况,向团队关注的通知渠道发送警报。
将安全视为持续的过程
没有一篇文章能涵盖所有内容,但在发布任何版本之前,你可以确认你的 API 符合以下基本标准:
- 所有接口仅通过 HTTPS 提供服务。
- 已实现 OAuth 或类似的令牌机制。
- 生成的 JWT 都设有过期时间。
- 所有接口都受到限制,其中登录接口的限制更为严格。
示例中使用了Express,但这些理念同样适用于其他后端框架,大多数框架要么直接集成这些工具,要么提供类似的原生功能。例如NestJS就是通过数据传输对象来实现请求验证的。相关框架的文档会展示每一层的标准实现方式。
同样的原则也是 Azure、Google Cloud 和 AWS 等平台上云原生部署的基准,这些平台会在其基础上添加各自的网关、身份服务以及托管式速率限制功能。针对云环境的特定实践需要单独处理,但上述这些层已足以让 API 大部分满足安全审查要求。
关键要点
- 将凭证验证与每次请求的认证分开:只需验证一次密码,之后即可依赖短时效的签名令牌。
- 认证不等于授权。需验证权限范围和角色,同时还要检查请求所涉及的每个对象的所有权。
- 对于静态数据,应使用每次操作都有唯一随机值的 AES-GCM 加密方式;而小数值及密钥交换则可使用非对称加密。
相关阅读
- 当 JWT 认证变为有状态时:在 Node 中使用服务器端会话的必要性 — 了解撤销黑名单和刷新存储如何使 JWT 认证变得脆弱,Postgres 支持的 Express 会话如何简化这一问题,以及 JWT 在何种场景下仍适用。
- 在 Node.js API 中构建可用于生产环境的 JWT 认证机制 — 如何对密码进行哈希处理、生成短有效期 JWT、添加刷新令牌与撤销机制、分离授权功能、抵御暴力攻击,以及验证认证失败时的处理是否正确。
- 使用硬件绑定密钥在 iOS 和 Android 上实现生物特征增强认证 — 了解为何仅靠 Face ID 无法向服务器证明身份,以及如何利用 Secure Enclave 和 Android Keystore 密钥来签署一次性服务器挑战信息。
- 从上传到URL:在Express中安全存储和提供用户文件 — 了解Express应用应将上传的文件保存在何处,express.static如何将文件夹映射为URL,以及哪些防护措施能防止用户上传内容成为安全漏洞。
- 使用JWT与两个中间件在Express中实现基于角色的访问控制 — 了解如何通过将JWT认证中间件与可重用的authorize()防护函数结合,在Express API中实施基于角色的访问控制,以及何时返回401错误码而非403错误码。