身份验证与授权:它们在代码中应处于何种位置
认证用于证明你的身份,授权则决定你能做什么。二者在中间件中的位置分别是401和403,以及将二者混淆所导致的错误。
来源: Authentication vs Authorization: The Distinction Most Developers Get Wrong
这两个词乍看之下几乎可以互换,但实际上各自掌控着系统逻辑中完全不同的部分。
如果你曾在本应返回403状态的情况下却返回了401,或者实现了登录流程却从未处理权限逻辑就宣称认证已完成,那你就已经遇到了因混淆这两个概念而产生的问题。让我们彻底理清它们之间的关系。
认证:验证身份
认证是系统确认用户确实为其自称的那个人的步骤。它只回答一个问题:这个身份是否真实有效?
这是一次性事件,通常在登录时发生,其结果会生成某种凭证——如会话、令牌或Cookie——从而使应用程序能够在无需每次请求都重新验证密码的情况下信任调用方。
实现身份认证的常见方式包括:
- 基于密码的认证——将输入的凭证与存储中的哈希值进行比对
- 基于令牌的认证(JWT)——在成功登录后生成带签名的令牌,随后在每次调用时都对该令牌进行验证
- OAuth 2.0 / OpenID Connect——将身份验证任务交由谷歌或GitHub等外部服务提供商处理
- API密钥——用于标识特定客户端或服务的固定密钥字符串
标准认证流程如下:
POST /api/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "hashed_and_verified_serverside"
}
Response: 200 OK
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
服务器会验证提交的凭证,确认无误后返回一个令牌。此后,在每次请求中该令牌都作为调用者的身份证明:
GET /api/profile
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
负责验证该令牌的中间件通常结构如下:
function authenticate(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'No token provided' });
jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => {
if (err) return res.status(401).json({ error: 'Invalid or expired token' });
req.user = decoded;
next();
});
}
注意这个中间件刻意省略了哪些内容。它从未决定用户被允许执行什么操作,只是确认令牌的有效性,并将解码后的负载附加到请求对象上。认证的工作范围仅此而已。
授权:验证权限
授权仅在认证成功之后才会启动。它解决的是一个完全不同的问题:这个已验证的身份是否被允许执行特定操作?
构建授权机制的一些常见方法:
- 基于角色的访问控制(RBAC)——权限被分配给诸如
admin、editor或viewer之类的角色
read:messages或write:files典型的授权检查是在已经通过身份验证的请求之上进行的:
function authorize(requiredRole) {
return (req, res, next) => {
if (req.user.role !== requiredRole) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
app.get('/api/admin/dashboard', authenticate, authorize('admin'), (req, res) => {
res.json({ data: 'sensitive admin data' });
});
这是两个功能不同的中间件函数,分别处理不同的任务,并按固定顺序执行:authenticate用于确认调用者的身份,而authorize则判断该调用者是否有权限继续操作。一个请求虽然通过了第一项检查,仍可能被第二项检查拒绝——而这种拒绝与身份无关。
真正体现这一差异的状态码
在这里,理论上的区别变成了可以在代码中实际验证的内容。
401 Unauthorized——这个名称具有误导性,但实际上它表示认证问题。这意味着服务器根本没有收到任何凭证,或者收到的凭证(包括令牌)无效或已过期。此时,服务器完全无法确定是谁发起了请求。
403 Forbidden — 这个状态码表示授权问题。服务器已经确认了请求者的身份,只是不允许该身份执行所需的操作或访问相应的资源。
// Authentication failure — identity unknown or invalid
res.status(401).json({ error: 'Invalid credentials' });
// Authorization failure — identity known, permission denied
res.status(403).json({ error: 'You do not have access to this resource' });
将本应是权限问题的情况返回401状态码是个常见错误。这会提示客户端重新登录,而实际问题在于登录本身没有问题——只是该账户缺乏所需的权限角色。仅这一错误就可能导致本应快速解决的权限问题,变成在客户端端进行漫长的、方向错误的调试过程,试图找出根本不存在的登录故障。
在实际架构中的体现
JWT载荷常常在无意中混入了这些不同类型的数据。令牌本应用于存储身份相关数据,比如sub、email、iat和exp等。至于是否还应包含权限数据,如role或scope,则属于需根据系统情况做出的设计决策。将角色信息直接放入令牌中虽然方便且能避免额外的查询操作,但也会带来相应代价:只有重新生成令牌后,用户的权限变更才会生效。这确实是一种权衡,而非没有缺点的捷径。
{
"sub": "user_12345",
"email": "user@example.com",
"role": "editor",
"iat": 1710000000,
"exp": 1710003600
}
基于服务器端会话构建的系统往往能将这两项功能进一步分离。会话标识符用于处理身份验证,而权限则会在每次请求时从数据库中重新获取。这样虽避免了上述的过时问题,但代价是每次请求都需要额外的数据库调用。
在微服务架构中,身份验证通常由网关或 Auth0、Keycloak、AWS Cognito 等身份提供者集中处理,这些服务会生成令牌。而授权功能则一般由各个独立的服务负责,它们会读取该令牌中的信息并执行自身的规则。因此,同一个身份验证事件可能会被十个不同的服务以不同方式处理,每个服务都会独立决定该身份允许执行哪些操作。
防止大多数安全漏洞的规则
认证必须在授权之前执行,两者绝不能合并为一次检查。
// Correct: sequential, separate concerns
app.post('/api/posts/:id/delete', authenticate, authorize('editor'), deleteHandler);
// Wrong: conflating identity with permission in one step
app.post('/api/posts/:id/delete', (req, res) => {
if (req.headers.authorization === 'valid-token-and-also-admin') {
// fragile, unscalable, and impossible to audit correctly
}
});
那种严格进行认证却跳过授权的系统虽然能完美确认身份,但却允许所有已确认身份的用户为所欲为——因此,一个低权限账户被攻破实际上就意味着整个系统都已被攻破。反之,如果在薄弱认证的基础上再加上精心设计的授权机制,则会出现另一个问题:虽然有严格限定的权限在保护着系统,但任何人仍可以不受阻碍地进入。
这两层都必须是稳固的,需按正确顺序应用,并被视为相互独立的模块。这就是其中的区别所在——一旦在状态码、中间件链以及令牌设计中正确体现这一点,一大类安全漏洞就会随之消失。