在 Node.js API 中构建可用于生产的 JWT 认证机制
如何对密码进行哈希处理、生成短时效的 JWT、添加刷新令牌与撤销机制、分离授权功能、抵御暴力攻击,以及验证认证失败时的处理是否正确。
来源: JWT Authentication in Node.js: What I Learned Building Secure APIs
在需求文档中,身份验证的描述仅有一行:用户通过邮箱和密码登录,API随即授予访问权限。但一旦开始实现它,这一行描述就会衍生出数十个问题:密码存储在哪里?如何进行验证?API如何在每次请求时识别调用者?令牌过期后会发生什么?刷新令牌的作用是什么?在连续多次登录失败后系统应如何处理?而在所有这些问题之下,最关键的问题是:如何防止登录系统本身成为应用程序中最薄弱的环节?
本指南将针对基于 PostgreSQL 的 Node.js 和 TypeScript API 所涉及的这些问题进行详细讲解,不会将身份验证视为单一的接口,而是将其视为一层包裹整个应用程序的安全防护。学习完成后,您将清晰了解每一层的功能、可能存在的隐患,以及判断系统是否真正准备好投入生产的检查清单。
存储密码哈希值,而非明文密码
首要原则是:密码并非普通数据,绝不能以明文形式存储。如下所示的用户记录是不可接受的,因为一旦数据库泄露,所有用户的密码都会被暴露;而且由于人们会重复使用密码,这也会导致他们在其他平台的账户安全受到威胁。
email: sachin@example.com
password: MyPassword123
应在保存密码之前,先用一种故意设计得较慢的密码哈希算法对每个密码进行处理。使用 bcrypt 包时,需先导入该库:
import bcrypt from "bcrypt";
然后在注册过程中对密码进行哈希处理。第二个参数 12 表示成本因子:该数值每增加1,所需的计算工作量就会翻倍,这样即便密码被窃取并用于离线猜测,成本也会大幅上升,同时仍能保证单次登录时的快速响应。
const passwordHash = await bcrypt.hash(password, 12);
在登录时无需解密。服务器会获取用户刚刚输入的密码,然后询问 bcrypt 该密码是否与存储的哈希值匹配。bcrypt.compare 会读取嵌入在哈希值中的盐值和成本因子,重新执行相应的计算,并返回一个布尔值。
const isValid = await bcrypt.compare(
password,
user.passwordHash
);
关键区别在于密码是被哈希处理而非加密,以便日后检索。注册完成后,您的应用程序绝不应需要也无法恢复原始密码。有两个实际注意事项:bcrypt仅考虑输入的前72字节,因此过长的密码会被悄悄截断;如果您希望选择对计算资源要求更高的方案,Argon2等算法则是常见的替代选择。
将登录视为工作流程而非查询
典型的登录接口会依次执行一系列步骤,从接收请求到验证、查找、比对以及生成令牌:
Client
↓
POST /api/auth/login
↓
Validate input
↓
Find user
↓
Compare password
↓
Create authentication tokens
↓
Return authentication response
关键在于每个步骤都可能失败,而每种失败都需要相应的处理措施。邮件格式可能错误,没有用户匹配该邮件;密码可能不正确,账户可能已被锁定;数据库也可能无法访问。这是一个涉及安全的重要工作流程,每个分支都应经过合理设计、记录日志并进行测试。
JWT是什么,又不是什么
JSON Web Tokens是一种在API中表示已验证身份的常用方式。成功登录后,服务器会生成一个包含用户信息的签名令牌。最简单的令牌内容可能仅包含主题和角色信息;sub是表示用户标识的标准字段。
{
"sub": "user-id",
"role": "user"
}
有效载荷是经过编码而非加密的,因此任何持有该令牌的人都能读取它。应仅包含标识符和简单声明,绝不能存放机密信息或个人数据。在后续请求中,客户端需使用Bearer机制将令牌放入Authorization标头中:
Authorization: Bearer <access-token>
在提供任何受保护资源之前,后端会验证令牌的签名及有效期。整个流程如下:
Login
↓
Credentials verified
↓
Access token issued
↓
Client sends token
↓
Server verifies token
↓
Protected API is allowed
关键在于,JWT本身并不能确保应用程序的安全。安全性同样取决于令牌的生成方式、客户端存储位置、验证严格程度(包括指定的签名算法)、有效期、是否可撤销,以及其在网络中的传输方式(必须通过HTTPS)。
保持访问令牌的短期有效性
访问令牌不应永久有效。如果有效期为数月的令牌通过日志文件或被攻破的设备泄露,攻击者便可长期使用它。
下面的示例使用jsonwebtoken,通过从环境变量中读取的密钥来签署包含用户ID和角色的令牌,并将expiresIn设置为15分钟。process.env.JWT_SECRET后的!是TypeScript的非空断言;在生产代码中,最好在启动时验证该变量是否存在,而非在每次调用时都进行断言。
const accessToken = jwt.sign(
{
sub: user.id,
role: user.role
},
process.env.JWT_SECRET!,
{
expiresIn: "15m"
}
);
合适的有效期取决于您的安全需求,但原则是通用的:限制用于解锁受保护资源的任何凭证的有效时间。较短的有效期意味着被盗的令牌会很快失去作用。
刷新令牌可解决可用性问题
如果访问令牌每15分钟就过期,用户将不得不不断重新登录,这是没人能接受的。刷新令牌解决了这个问题。在登录时,客户端会收到两种凭证:一种用于API调用的短期访问令牌,另一种则是长期有效的刷新令牌,其唯一作用就是在旧令牌过期时获取新的访问令牌。
┌─────────────────┐
│ Login │
└────────┬────────┘
↓
┌─────────────────┐
│ Access Token │
│ Short Lifetime │
└────────┬────────┘
│
API Requests
│
↓
Token expires
│
↓
┌─────────────────┐
│ Refresh Token │
│ Longer Lifetime │
└────────┬────────┘
↓
New Access Token
这种分离方式能让访问令牌保持较短长度,同时让会话体验更加连贯。但它也带来了新的责任,因为刷新令牌现在成了更重要的凭证。必须谨慎存储它(通常存放在网页上的httpOnly、Secure属性的cookie中,而非JavaScript可访问的存储空间),仅发送到刷新端点,并由服务器严格验证。如需更深入的设计讨论,请参阅我们关于Node.js认证系统中的刷新令牌策略的指南。
撤销机制的规划
令许多团队感到意外的是,自包含令牌的一个特性是:即使服务器上发生了某些变化,这些令牌依然有效。比如用户登出、管理员禁用账户、已知已被盗用的刷新令牌,或是需要终止所有会话的安全事件。如果设计中没有撤销机制,那么删除或更新数据库记录对已经发放的令牌没有任何影响;该令牌会一直正常使用,直到过期为止。
正因如此,许多认证系统会为每位用户保存服务器端的刷新令牌或会话记录,通常每台设备对应一条记录:
User
├── Refresh Token A
├── Refresh Token B
└── Refresh Token C
有了这样的机制,服务器就可以将特定的令牌标记为已撤销,终止用户的所有会话,并拒绝从已被撤销的刷新令牌中生成新的访问令牌。这体现了一个合理的权衡:JWT减少了服务器需要保留的会话状态,但安全的身份验证通常仍需要一些服务器端的状态信息。较短的访问令牌有效期加上可撤销的刷新令牌是平衡这两者的一种常见方法。
在中间件中强制实施身份验证
在每个控制器中重复进行令牌检查容易出错;一旦遗漏某次检查,就会留下安全漏洞。中间件可以将相关逻辑集中处理。在Express中,受保护的路由只需在其控制器之前添加一个authenticate函数即可:
app.get(
"/api/profile",
authenticate,
profileController
);
该中间件有着明确且简单的职责:
- 读取
Authorization请求头。
这样的控制流程很容易理解:无效或缺失的令牌会返回401错误,只有有效的令牌才能传递给控制器。
Request
↓
Authentication Middleware
↓
Token valid?
↙ ↘
No Yes
↓ ↓
401 Controller
↓
Response
身份验证与授权是不同的检查过程
这两个词经常被互换使用,但它们回答的是不同的问题。身份验证用于确认调用者是谁,而授权则用于确定该调用者被允许执行什么操作。我们在关于身份验证与授权在代码中的应用位置的文章中更深入地探讨了二者之间的界限。
想象一个具有三种角色的应用程序:
Admin
Staff
User
所有三种角色的成员都可以完成完整的身份验证,但他们的权限差异很大。普通用户可能被允许查看自己的个人资料、列出问题以及创建新问题:
GET /api/profile
GET /api/issues
POST /api/issues
管理员则还可以被允许修改用户账户和删除问题:
PATCH /api/users/:id
DELETE /api/issues/:id
拥有有效的JWT仅能证明身份,却不能为所有操作授予权限。安全的后端需要同时具备这两层防护机制:通常先是身份验证中间件,再针对每条路由进行角色或权限检查,理想情况下还应加入所有权检查,这样用户才能编辑自己的问题,而无法操作他人的问题。
防止重复登录失败
身份验证设计必须考虑到攻击者的可能性,而不仅仅是合法用户。除非有措施加以阻止,否则自动化脚本可以无限次尝试密码。最简单的防御方式是使用计数器,在连续多次失败后触发临时锁定:
Attempt 1 → Failed
Attempt 2 → Failed
Attempt 3 → Failed
Attempt 4 → Failed
Attempt 5 → Failed
↓
Temporary lock
在实际应用中,需根据具体需求结合多种防护措施:
- 限制对登录端点的请求频率。
- 实施临时账户锁定。
需谨慎选择,因为某种防护措施可能反而成为服务拒绝攻击的途径。如果只需输入错误的邮箱五次就能锁定账户,攻击者就可以故意将真实用户拒之门外。其目标不仅仅是失败尝试后进行阻断,还要让自动化攻击成本高昂,同时确保合法用户仍能登录,例如通过结合账户级别的延迟与IP级别的速率限制,而非单纯采用强制锁定措施。
切勿轻信客户端提供的身份信息
一种危险的思维方式是仅因前端发送了数据就盲目相信它。假设请求体中声明了调用者的用户ID和角色:
{
"userId": "123",
"role": "admin"
}
没有任何机制能阻止恶意客户端修改这些字段。后端绝不能假设它们的值是真实的。身份信息必须来自服务器已验证过的凭证,也就是说,用户ID和角色是从经过验证的令牌中读取的(或通过令牌中的主体信息从数据库中获取),权限检查也基于这些信息进行:
Request
↓
Verify JWT
↓
Read authenticated user ID
↓
Check permissions
↓
Access resource
服务器才是最终权威。这一原则不仅适用于身份验证:客户端可以通过隐藏用户无法使用的按钮来提升体验,但绝不能依赖它来执行安全规则。
避免在错误消息中泄露信息
安全性体现在诸如错误提示的措辞等细节上。比较一下表示邮箱不存在的响应:
Email does not exist.
与那种故意保持模糊的响应相比:
Invalid email or password.
第一种情况会告知攻击者哪些邮箱已注册,这便于进行账户枚举,同时也让有针对性的密码猜测变得更加容易。第二种情况则允许合法用户在不确认任何信息的情况下重新尝试登录。需注意,通过响应时间或注册及密码重置接口同样可能实现枚举,因此这些场景也需要采取同样的防护措施。
更广泛的规则还涵盖了数据库错误和未处理的异常。客户端需要的是能够用于采取行动的错误信息,而非堆栈跟踪、SQL错误消息或内部标识符。
避免在源代码中存放机密信息
身份验证依赖于诸如签名密钥、数据库连接字符串以及第三方API密钥等机密信息:
JWT_SECRET
DATABASE_URL
API_KEY
这些内容绝不能被提交到代码仓库中。一旦其他人能够读取代码,这类硬编码的密钥就会带来安全风险:
const secret = "my-super-secret-key";
从环境变量中读取密钥是最起码的改进措施:
const secret = process.env.JWT_SECRET;
在正式环境中,应通过正规的配置文件或密钥管理系统来管理敏感信息。对于公共代码仓库而言,这一点尤为重要——一旦不小心将密钥提交进去,几乎会立即引发严重安全事件;遇到这种情况时,应更换密钥而非仅仅删除相关提交记录。
测试认证是否正确失败
手动测试并不适合用于验证认证功能,因为真正重要的其实是那些应该失败的场景。一个基本的自动化测试套件应当覆盖流程中所有成功和失败的路径。
注册功能
格式正确的注册请求会成功,而格式错误的邮件、无效输入以及重复账户都会被拒绝:
Valid registration → Success
Invalid email → Rejected
Weak/invalid input → Rejected
Duplicate account → Rejected
登录
正确的凭据可以成功登录;错误的密码、未知账户以及被锁定的账户都会被拒绝:
Correct password → Success
Wrong password → Rejected
Unknown account → Rejected
Locked account → Rejected
受保护路径
缺失、无效或过期的令牌都会导致401错误,有效的令牌则可以通行;而拥有有效令牌但缺乏所需权限的账户则会收到403错误。需要注意两者的区别:401表示“我们不知道你是谁”,而403则表示“我们知道你是谁,但拒绝你的请求”。
No token → 401
Invalid token → 401
Expired token → 401
Valid token → Allowed
Valid token + insufficient role → 403
这样的测试不仅能够确认身份验证功能正常,还能证明它在出现错误时的表现方式,而大多数真正的漏洞往往就隐藏在这些错误处理机制中。
分层身份验证模型
可以将身份验证想象成一组独立的层次结构,每一层都有特定的职责,从凭证处理到密码验证、令牌生成、令牌校验与授权,最终再到受保护的资源本身:
┌─────────────────────────────┐
│ User Credentials │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Password Verification │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Token Issuance │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Token Validation │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Authorization │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Protected Resource │
└─────────────────────────────┘
在整个层次结构之外还有一层贯穿各处的功能:监控、日志记录、速率限制、安全配置以及测试。
生产环境就绪检查清单
在认定某个身份验证系统已经完备之前,请确认:
- 密码使用强大且计算速度较慢的算法进行哈希处理。
- 访问令牌的过期时间适当且较短。
- 刷新令牌的存储、传输和校验过程都十分安全。
- 每条受保护的路由都会进行身份验证。
- 授权是在身份验证之后作为独立步骤来执行的。
相比仅检查登录是否正常,这份清单是更有效的检测标准。
核心要点
身份验证并非可以在项目后期才添加的功能。它影响着数据库设计、API结构、中间件、前端行为、错误处理、日志记录、测试与部署,尤其是安全性。一个登录接口只需几行代码;而要在真实用户、攻击者、过期的令牌、格式错误的请求以及生产环境故障出现时都能保证系统安全运行,那则是一个更为复杂的工程问题。
接下来需要深入研究的领域是刷新令牌的安全性:刷新令牌应存储在何处,是否以及如何更换它们,如何检测旧令牌的重复使用,实际中的撤销操作是怎样的,以及如何在多台设备上实现登出功能。所有这些决策都遵循与前述内容相同的原则:只有将安全性融入每一个设计选择中,而非事后才添加的层面,才能真正保障安全。