在 RESTful 应用程序中使用 JWT 进行身份验证
问题
使用 MEAN 堆栈构建的应用程序通常在后端使用 Node、MongoDB 和 Express 来实现业务逻辑,并以 RESTful 接口作为前端。大部分工作都在后端完成,而 Angular 则作为 MVC(模型-视图-控制器)模式中的增强视图。将业务规则和逻辑保留在后端意味着应用程序与视图无关;从 Angular 切换到 React 或直接使用 jQuery 或 PHP 应该可以获得相同的功能。
我们经常需要保护一些后端路由,使其仅对经过身份验证的用户可用。挑战在于,我们的后端服务应该是无状态的,这意味着我们需要一种方法让前端代码在每次请求时提供身份验证证明。同时,我们不能信任任何前端代码,因为它不受我们控制。我们需要一种完全由后端管理的、无可辩驳的身份验证证明机制。我们还希望该机制不受客户端代码的控制,并且以一种难以或不可能被伪造的方式实现。
解决方案
JSON Web Tokens(JWT)是满足这些需求的一个很好的解决方案。Token 本质上是一个 JavaScript 对象,包含三部分:
- 包含用于生成令牌的算法信息的标头
- 具有一个或多个声明的主体
- 基于标头和正文的加密签名
JWT 的正式描述见RFC7519。它本身并不具备身份验证的功能——它是一种在双方之间封装和传输数据的机制,以确保信息的完整性。我们可以利用这一点,让客户端无需任何参与即可证明其身份。具体流程如下:
- 客户端向服务器进行身份验证(或通过第三方,如 OAuth 提供商)
- 服务器使用只有服务器知道的秘密创建一个签名的 JWT,描述身份验证状态和授权角色
- 服务器在标记为httpOnly 的会话 cookie 中将 JWT 返回给客户端
- 每次请求时,客户端都会自动将 cookie 和附带的 JWT 发送到服务器
- 服务器验证每个请求的 JWT,并决定是否允许客户端访问受保护的资源,返回请求的资源或错误状态
使用 Cookie 传输 JWT 提供了一种简单、自动化的方式,可以在客户端和服务器之间来回传递令牌,同时还能让服务器控制 Cookie 的生命周期。将 Cookie 标记为httpOnly意味着客户端函数无法访问它。此外,由于令牌是使用只有服务器知道的密钥签名的,因此很难甚至不可能伪造令牌中的声明。
本文讨论的实现使用了一种简单的基于哈希的签名方法。JWT 的头部和主体采用 Base64 编码,然后对编码后的头部和主体以及服务器端密钥进行哈希处理,生成签名。另一种选择是使用公钥/私钥对来签名和验证 JWT。在本例中,JWT 仅在服务器上处理,因此使用签名密钥没有任何好处。
代码中的 JWT 授权
让我们看一下实现工作流程的一些代码。以下示例中使用的应用程序依赖于 Twitter 的第三方 OAuth 身份验证,并且用户在会话之间只会保留少量个人资料信息。身份验证成功后返回的 Twitter 访问令牌将用作 MongoDB 数据库中用户记录的密钥。该令牌一直有效,直到用户注销或关闭浏览器窗口后重新进行身份验证(从而使包含 JWT 的会话 Cookie 失效)。请注意,为了便于阅读,我简化了错误处理。
依赖项
以下代码示例中使用了两个便利包:
- cookie-parser - Express 中间件,简化 cookie 处理
- jsonwebtoken - 基于 node-jws 包,抽象 JWT 的签名和验证
我还使用 Mongoose 作为 mongoDB 的顶层;它通过模式提供 ODM 以及几种方便的查询方法。
创建 JWT 并将其放入会话 cookie 中
Twitter 身份验证完成后,Twitter 会在应用程序上调用一个回调方法,返回访问令牌和密钥,以及用户的 Twitter ID 和用户名等信息(传入results对象)。用户的相关信息存储在数据库文档中:
User.findOneAndUpdate( {twitterID: twitterID}, { twitterID: twitterID, 名称:results.screen_name, 用户名:results.screen_name, twitter访问令牌:oauth_access_token, twitterAccessTokenSecret:oauth_access_token_secret }, {'upsert': 'true'}, 函数(错误,结果){ 如果(错误){ console.log(错误) } 别的 { console.log("已更新", results.screen_name, "在数据库中。") } })
如果不存在文档, upsert选项会指示 mongoDB 创建文档,否则会更新现有文档。
接下来,组装一个 JWT。jsonwebtoken包负责创建 JWT 的头部,所以我们只需在主体部分填写 Twitter 访问令牌即可。授权检查时,我们将使用这个访问令牌在数据库中查找用户。
const jwtPayload = { twitter访问令牌:oauth_access_token }
然后对 JWT 进行签名。
const authJwtToken = jwt.sign(jwtPayload, jwtConfig.jwtSecret)
jwtSecret是一个字符串,可以是用于所有用户的单个值(就像本应用程序中那样),也可以是每个用户的值,在这种情况下,它必须与用户记录一起存储。针对每个用户的密钥,一种策略可能是使用 Twitter 返回的 OAuth 访问令牌密钥,但如果 Twitter 的响应被拦截,则会带来轻微的风险。将 Twitter 密钥与服务器密钥连接起来是一个不错的选择。在授权客户端请求时,在签名验证期间会用到该密钥。由于它存储在服务器上,并且从不与客户端共享,因此这是一种验证客户端提供的令牌是否确实由服务器签名的有效方法。
签名的 JWT 被放置在一个 Cookie 中。该 Cookie 被标记为httpOnly,这限制了它在客户端的可见性,并且其过期时间设置为零,使其成为仅限会话的 Cookie。
const cookieOptions = { httpOnly: true, 过期时间:0 } res.cookie('twitterAccessJwt',authJwtToken,cookieOptions)
请记住,cookie 对于客户端代码是不可见的,因此如果您需要一种方法来告诉客户端用户已经过身份验证,您将需要向另一个可见的 cookie 添加一个标志,或者以其他方式将指示授权状态的数据传回客户端。
为什么要有 cookie和JWT?
我们当然可以将 JWT 作为普通对象发送回客户端,并使用其中包含的数据来驱动客户端代码。有效负载未加密,仅经过 Base64 编码,因此客户端可以访问。它可以放在会话中以便与服务器之间传输,但这必须在服务器和客户端的每个请求-响应对中都执行,因为这种会话变量不会自动来回传递。
另一方面,Cookie会随每个请求和响应自动发送,无需任何额外操作。只要 Cookie 未过期或被删除,它就会随每个请求一起返回服务器。此外,将 Cookie 标记为httpOnly可以将其隐藏在客户端代码中,从而减少其被篡改的可能性。此特定 Cookie 仅用于授权,因此客户端无需查看或与其交互。
授权请求
此时,我们已经向客户端提交了一个由服务器签名的授权令牌。每次客户端向后端 API 发出请求时,该令牌都会通过会话 Cookie 传递。请记住,服务器是无状态的,因此我们需要在每次请求时验证令牌的真实性。该过程分为两个步骤:
- 检查令牌上的签名,证明令牌未被篡改
- 验证与令牌关联的用户是否在我们的数据库中
- [可选] 检索该用户的一组角色
仅仅检查签名是不够的——这只能告诉我们令牌中的信息自离开服务器以来没有被篡改,而不能保证令牌的所有者就是他们声称的那个人;攻击者可能窃取了 cookie 或以其他方式截取了它。第二步让我们确信用户是有效的;数据库条目是在 Twitter OAuth 回调中创建的,这意味着用户刚刚通过 Twitter 进行了身份验证。令牌本身位于会话 cookie 中,这意味着它不会持久保存在客户端(它保存在内存中,而不是磁盘上),并且设置了httpOnly标志,这限制了它在客户端的可见性。
在 Express 中,我们可以创建一个中间件函数来验证受保护的请求。并非所有请求都需要这种保护;应用程序的某些部分可能对未登录用户开放。对 URI /db 的受限访问 POST 请求如下所示:
// POST 创建新用户(仅适用于登录用户) // router . post ( '/db' , checkAuthorization , function (req, res, next) { ... }
在此路由中,checkAuthorization 是一个验证客户端发送的 JWT 的函数:
const checkAuthorization = function (req, res, next) { // 1. 查看请求中是否有令牌...如果没有,则立即拒绝 // const userJWT = req. cookies . twitterAccessJwt if (! userJWT ) { res.send (401 ,‘授权令牌无效或缺失’ ) }
//2. 有一个令牌;检查它是否有效并检索有效负载 // else { const userJWTPayload = jwt . verify ( userJWT , jwtConfig . jwtSecret ) if (! userJWTPayload ) { // 由于令牌无效,因此将其销毁 // res. clearCookie ( 'twitterAccessJwt' ) res.send (401 ,‘授权令牌无效或缺失’ ) } 别的{
//3. 有一个有效的令牌...看看它是否是我们作为登录用户在数据库中拥有的令牌 // User . findOne ({ 'twitterAccessToken' : userJWTPayload . twitterAccessToken }) .then (函数(用户){ 如果(!用户){ res.send ( 401 , ‘用户当前未登录’ ) } else { console.log ( '有效用户: ' , user.name ) 下一个() } }) } } }
假设授权 Cookie 存在(步骤 1),然后使用存储在服务器上的密钥检查其签名是否有效(步骤 2)。如果签名有效,则jwt.verify返回 JWT 有效负载对象;如果签名无效,则返回 null。如果 Cookie 或 JWT 缺失或无效,则会导致客户端返回 401(未授权)响应;如果 JWT 无效,则 Cookie 本身会被删除。
如果步骤 1 和 2 均有效,我们将使用 Twitter 访问令牌作为密钥,检查数据库是否有 JWT 中携带的访问令牌的记录。如果有记录,则表明客户端已获得授权。步骤 3 末尾对next()的调用将控制权交给中间件链中的下一个函数,在本例中,该函数即 POST 路由的剩余部分。
注销用户
如果用户明确注销,则会调用后端路由来执行工作:
//此路由将用户注销: //1. 删除 cookie //2. 从 mongo 中的用户记录中删除访问密钥和密码 // router . get ( '/logout' , checkAuthorization, function (req, res, next) { const userJWT = req. cookies . twitterAccessJwt const userJWTPayload = jwt . verify ( userJWT , jwtConfig . jwtSecret ) res.clearCookie (' twitterAccessJwt ' )User.findOneAndUpdate({ twitterAccessToken:userJWTPayload.twitterAccessToken } , { twitterAccessToken : null, twitterAccessTokenSecret : null }, 函数(err,result){ if(err){ console.log ( err ) } else { console.log ( "已删除访问令牌" ,result.name ) } 水库渲染('twitterAccount',{ loggedIn:false }) }) })
我们再次检查用户是否已登录,因为我们需要 JWT 的验证内容才能更新用户的数据库记录。
如果用户直接关闭浏览器标签页而没有注销,包含 JWT 的会话 Cookie 将在客户端被移除。下次访问时,JWT 将不再通过 checkAuthorization 进行验证,用户将被定向到登录页面;成功登录后,数据库中的访问令牌及其关联的密钥将更新。
评论
没有特别的顺序...
某些服务会为访问令牌设置较短的过期时间,并提供将“刷新”令牌兑换为新访问令牌的方法。在这种情况下,需要额外执行一个步骤来更新存储在会话 Cookie 中的令牌。由于对第三方服务的访问是在服务器端处理的,因此这对客户端来说是透明的。
此应用只有一个角色:登录用户。对于需要多个角色的应用,应将角色存储在数据库中,并在每次请求时检索。
关于 checkAuthorization,架构上出现了一个问题。问题是,谁应该负责处理无效用户?实际上,checkAuthorization是否应该返回一个可供每个受保护路由使用的布尔值?让checkAuthorization处理无效情况可以集中处理此行为,但代价是会损失路由的灵活性。我对此持两种观点……未经授权的用户就是未经授权,所以在 checkAuthorization 中处理该功能是合理的;然而,在某些情况下,路由可能会返回未经身份验证的用户的数据子集,或者为已授权用户添加一些额外的信息。对于这个特定示例,集中式版本可以正常工作,但您需要根据自己的用例来评估该方法。
本例中的路由仅渲染了一个 Pug 模板,用于显示用户的 Twitter 账户信息,并使用一个标志 (loggedIn) 来显示和隐藏 UI 组件。更复杂的应用需要一种更简洁的方式来让客户端了解用户的状态。
带有示例代码的要点可在 gist:bdb91ed5f7d87c5f79a74d3b4d978d3d获取。
最初发表于我在波士顿大学的博客
文章来源:https://dev.to/perrydbucs/using-jwts-for-authentication-in-restful-applications-55hc