JWT 客户端身份验证终极指南(停止使用本地存储!!!)

2025-05-24

JWT 客户端身份验证终极指南(停止使用本地存储!!!)

大家好,我叫 Kati Frantz,非常感谢您阅读本教程。我想讲讲如何在客户端有效且安全地处理 JWT。

目前业界最流行的做法是将 JWT 保存在 Cookie 或本地存储中。我已经这样做了几年,甚至还教过其他人也这么做,但我之前一直没觉得这有什么大不了的,直到我参与开发的一个应用程序被黑客入侵。

这是一次XSS攻击。恶意人员在客户端浏览器上运行恶意代码,直接攻击你的应用程序。

现在,他们可以这样做来访问本地存储或 cookie 并从那里提取 JWT。

会话中使用的这些令牌通常是长期有效的,攻击者可以长时间访问您的 API。

我们今天要讨论的解决方案首先是防止我们将令牌保存在危险的地方,其次是实施另一个解决方案,确保即使攻击者设法获取令牌,对 API 的访问权限也会几乎立即过期。

让我们开始吧。

对于本教程,我们首先需要一个真实的项目。我已经建立了一个包含用户注册、登录和注销的示例项目。

/api文件夹具有功能齐全的 graphql 和 auth 服务器,仅使用 20 行Tensei.js



const { auth } = require('@tensei/auth')
const { tensei } = require('@tensei/core')
const { graphql } = require('@tensei/graphql')

tensei()
    .plugins([
        auth()
            .user('Customer')
            .plugin(),
        graphql()
            .middlewareOptions({
                cors: {
                    credentials: true,
                    origin: ['http://localhost:3000']
                }
            })
            .plugin()
    ])
    .databaseConfig({
        type: 'sqlite',
        dbName: 'tensei.sqlite',
    })
    .start()
    .catch(console.log)


Enter fullscreen mode Exit fullscreen mode

该文件夹是使用create react app/client生成的 React.js 项目。我们有三个路由:LoginRegisterDashboard

用户注册

注册页面

当用户注册新账户时,我们会向后端发出请求以获取 JWT,以便自动登录。此时,我们通常会将 JWT 设置为本地存储,但我们不会这样做。以下是注册函数的实现:



client
      .request(register, {
        name: name.value,
        email: email.value,
        password: password.value,
      })
      .then(({ register_customer: { customer, token } }) => {
        client.setHeader("authorization", `Bearer ${token}`);

        setCustomer(customer);

        history.push("/");
      })


Enter fullscreen mode Exit fullscreen mode

我们没有将其设置token为本地存储,而是将其保存在内存中。在这里,我们将其设置在 HTTP 客户端上,以便我们可以向 API 发出后续的经过身份验证的请求。

接下来,我们设置客户并重定向到仪表板。

当我们收到后端的响应时,会发生一些非常重要的事情。让我们看一下后端的响应:


后端设置了一个在响应中HttpOnly调用的 Cookie ___refresh_token。此 Cookie 具有一个独特的属性,即无法从客户端访问。这意味着如果您document.cookie在开发者控制台中运行,您将看不到该___refresh_tokenCookie。

这是因为HttpOnlycookie 只能与服务器交换,而不能使用客户端 javascript 访问。

使用这种 cookie 来设置刷新令牌可以为我们带来额外的安全性,并确保令牌不会落入坏人之手。

了解刷新令牌

我们从 API 的 JSON 响应中收到的令牌是访问令牌。这种类型的令牌授予客户访问 API 资源的权限。

访问令牌应在大约 10 到 15 分钟内过期,以便如果它落入坏人之手,它会尽快失效。

另一方面,刷新令牌不授予访问权限。相反,它可以用来请求新的访问令牌。这样,在访问令牌过期之前,您可以静默地请求新的访问令牌,以保持客户登录状态。

处理静默刷新

注册后,客户会被重定向到仪表板,并且由于他们已登录,所以他们可以访问仪表板。当她刷新页面或在新选项卡中打开应用程序时会发生什么?

好吧,由于我们只在内存中设置令牌,因此客户将失去访问权限并被重定向到登录页面。

这并不令人愉快,我们需要以某种方式坚持客户的会话。

这时,静默刷新就派上用场了。在将客户真正重定向到登录屏幕之前,我们需要检查用户是否拥有活动会话。为此,我们调用 API 请求新的访问令牌。

执行此操作的一个好时机是当应用程序挂载时,当我们发出此请求时向用户显示加载指示器:



  const client = useClient();
  const [customer, setCustomer] = useState(null);
  const [working, setWorking] = useState(true);

  const refreshToken = () => {
    client
      .request(refresh_token)
      .then(({ refresh_token: { customer, token, expires_in } }) => {
        client.setHeader("authorization", `Bearer ${token}`);

        setCustomer(customer);
      })
      .catch(console.log)
      .finally(() => {
        setWorking(false);
      });
  };

  useEffect(() => {
    refreshToken();
  }, [])


Enter fullscreen mode Exit fullscreen mode

应用程序启动后,我们会立即向后端发送 HTTP 请求,以刷新访问令牌。由于___refresh_token已在客户浏览器中设置,因此它会随请求一起发送。

后端获取 cookie,验证该 cookie,并发回包含客户信息的新访问令牌。

然后,我们token在 HTTP 客户端上设置后续请求,并将客户状态设置为该值。这意味着每次客户访问应用程序时,都会从 API 获取他们的会话,并自动登录。

这解决了第一个问题,客户有一个持久会话,但访问令牌将在 10 分钟后过期,我们也需要处理这种情况。

API 还会响应 JWT 需要多长时间才能过期,因此我们可以使用此值来知道何时静默调用 API 来获取新的访问令牌。



  const client = useClient();
  const [customer, setCustomer] = useState(null);
  const [working, setWorking] = useState(true);

  const refreshToken = () => {
    client
      .request(refresh_token)
      .then(({ refresh_token: { customer, token, expires_in } }) => {
        client.setHeader("authorization", `Bearer ${token}`);

        setTimeout(() => {
          refreshToken()
        }, (expires_in * 1000) - 500)

        setCustomer(customer);
      })
      .catch(console.log)
      .finally(() => {
        setWorking(false);
      });
  };

  useEffect(() => {
    refreshToken();
  }, []);


Enter fullscreen mode Exit fullscreen mode

我们使用该expires_in值来设置setTimeout刷新令牌的方法。这意味着在令牌过期前几毫秒,该refreshToken()方法会被再次调用,并设置一个新的访问令牌。

太好了,我们现在可以让客户始终保持登录状态,并且访问令牌仅存储在内存中。

处理注销

当用户需要注销时会发生什么?我们无法___refresh_token从客户端 JavaScript 访问 Cookie,那么如何清除它呢?

我们需要调用 API,而 API 会使 失效___refresh_token。在仪表盘页面上,当logout点击 按钮时,我们将调用以下函数:



  const logout = () => {
    client.request(remove_refresh_token).finally(() => {
      history.push("/auth/signin");

      setCustomer(null);
    });
  };


Enter fullscreen mode Exit fullscreen mode

我们remove_refresh_token在后端调用端点,响应使___refresh_tokencookie 无效,如下所示:

后端响应包含一个Set-Cookie标头,该标头将标头Max-Age的设置为并将其值设置为,从而使其过期并无效。___refresh_token0''

然后我们将客户设置为null并重定向到登录页面。

跨域注意事项

在示例项目中,客户端和服务器分别在不同的域上运行。您的应用程序很可能也面临同样的情况。为了允许两个域之间交换敏感信息,您需要在客户端和服务器上分别进行一些配置。

在服务器上,首先,您需要启用CORS,允许客户端域从服务器请求资源。其次,您需要允许交换凭据。这将通知服务器接受来自传入客户端请求的敏感信息,例如 Cookie。在我们的演示服务器上,我们将其配置如下:



.middlewareOptions({
    cors: {
        credentials: true,
        origin: ['http://localhost:3000']
    }
})


Enter fullscreen mode Exit fullscreen mode

Tensei.jsapollo-server-express在后台使用 graphql 服务器,并将此配置直接传递给它。

在客户端,你需要配置 HTTP 客户端(例如 Axios 或 Fetch),以便在向外部 API 发出请求时包含敏感凭据。在我们使用的演示项目中graphql-request,我们进行了如下配置:



import { GraphQLClient } from "graphql-request";

export default new GraphQLClient(
process.env.REACT_APP_API_URL || "http://localhost:4500/graphql",
{
credentials: "include",
}
)

Enter fullscreen mode Exit fullscreen mode




结论

当构建非面向客户的应用程序、用于教程或仅仅是有趣的项目时,安全性可能不是什么大问题,但如果使用真实的客户数据,安全性必须是重中之重。

我强烈建议在构建现实世界中使用的应用程序时实施非常安全的 JWT 身份验证系统。

请考虑在 Twitter 上关注我,并查看tensei.js并给它一颗星。

非常感谢您阅读到这里,我希望这能改变您处理 JWT 的方式。

文章来源:https://dev.to/bahdcoder/the-ultimate-guide-to-jwt-client-side-auth-stop-using-local-storage-3an9
PREV
如何用 Python 编写一个简单的猜数字游戏
NEXT
VerbalExpressions - 轻松使用正则表达式