使用 SSR 时,使用 HttpOnly Cookie 在 Next.js 中检测客户端身份验证

2025-06-09

使用 SSR 时,使用 HttpOnly Cookie 在 Next.js 中检测客户端身份验证

这里解释的架构仅支持 SSR。如果您需要支持静态优化,请阅读我的后续文章

最佳安全做法是将会话标识符或令牌存储在 HttpOnly Cookie 中。HttpOnly Cookie 不适用于 JavaScript,它们只会发送到服务器。这可以防止第三方脚本劫持会话。但是,这也会阻止您自己的 JavaScript 访问令牌。对于所有页面都进行服务器渲染的应用来说,这通常没有问题,但是对于在客户端渲染的页面,我们该如何在 Next.js 中处理这个问题呢?

首先,我们要知道客户端不会验证会话,只有服务器会验证。客户端通常只会检查会话 Cookie 是否已设置,并假设其有效。换句话说,客户端会将 Cookie 视为布尔值来回答以下问题:用户是否已登录?

为什么客户端需要知道用户是否登录?这是一种优化。想象一下,如果客户端不知道用户是否已通过身份验证,客户端渲染的应用会如何表现。首次访问网站时,您会看到主页,页眉处有一个登录按钮。如果用户登录,登录代码应该知道将用户发送到已登录的主页。如果用户点击了返回按钮怎么办?应用会再次渲染登录页面。这很不理想。为什么要允许用户再次登录?我们是在浪费用户的时间。

几天后,同一位用户点击书签加载了他们已登录的主页。页面渲染了完整的shell和一个加载旋转图标,用于获取我们需要的数据来填充最新活动。糟糕!服务器返回401错误。用户未通过身份验证。现在,用户被跳转到了登录页面。我们只是浪费了他们几秒钟的时间、一点带宽以及我们的一些服务器资源(一次点击虽然影响不大,但随着时间的推移,累积起来就非常麻烦了)。

这些简单的例子表明,允许客户端知道用户是否已通过身份验证仅仅是一种优化,主要是为了辅助路由和渲染。我们希望避免渲染页面和获取用户无法看到的数据。我们还希望阻止已通过身份验证的用户参与注册、登录和重置密码流程。

在 Next.js 中实现这一点的一种方法是使用页面级 HOC。最好有两个 HOC:withAuth()一个仅在用户通过身份验证时渲染页面,withoutAuth()另一个仅在用户未通过身份验证时渲染页面。如果能够指定未满足所需身份验证条件时应将用户发送到何处,那就太好了,因此第一个参数将是页面组件,第二个参数将是 URL。

auth HOC 需要访问已认证状态。这可以通过一个钩子来实现:useIsAuthenticated()。该钩子需要从某个全局状态存储中提取值。这将通过Context API来实现。

import React from 'react';

const AuthContext = React.createContext({
  isAuthenticated: false,
  setAuthenticated: () => {}
});

/**
 * The initial value of `isAuthenticated` comes from the `authenticated`
 * prop which gets set by _app. We store that value in state and ignore
 * the prop from then on. The value can be changed by calling the
 * `setAuthenticated()` method in the context.
 */
export const AuthProvider = ({
  children,
  authenticated
}) => {
  const [isAuthenticated, setAuthenticated] = React.useState(authenticated);
  return (
    <AuthContext.Provider
      value={{
        isAuthenticated,
        setAuthenticated
      }}
    >
      {children}
    </AuthContext.Provider>
  );
};

export function useAuth() {
  const context = React.useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}

export function useIsAuthenticated() {
  const context = useAuth();
  return context.isAuthenticated;
}

接受AuthProvider一个authenticated表示初始认证值的 prop。初始值将被计算并传递给自定义 AppAuthProvider中的

请注意,我们还包含了两个钩子,它们使我们的应用程序的其余部分可以轻松地了解当前的身份验证状态:useAuth()useIsAuthenticated()。但在使用它们之前,我们必须将其添加AuthProvider到我们的应用程序中。

import React from 'react';
import App from 'next/app';
import { AuthProvider } from '../providers/Auth';
// Be sure to install this package for parsing cookies
import cookie from 'cookie';

class MyApp extends App {
  render() {
    const { Component, pageProps, authenticated } = this.props;
    return (
      <AuthProvider authenticated={authenticated}>
        <Component {...pageProps} />
      </AuthProvider>
    );
  }
}

MyApp.getInitialProps = async (appContext) => {
  let authenticated = false;
  const request = appContext.ctx.req;
  if (request) {
    request.cookies = cookie.parse(request.headers.cookie || '');
    authenticated = !!request.cookies.session;
  }

  // Call the page's `getInitialProps` and fill `appProps.pageProps`
  const appProps = await App.getInitialProps(appContext);

  return { ...appProps, authenticated };
};

export default MyApp;

现在,身份验证状态已初始化、存储在上下文中,并通过身份验证钩子获取,我们就可以创建withAuth()withoutAuth()高阶组件了。它们的 API 和通用逻辑几乎完全相同。唯一的区别是一个检查 true,另一个检查 false。因此,首先我们将构建一个它们共用的高阶组件。我们称之为withConditionalRedirect()

import { useRouter } from 'next/router';

function isBrowser() {
  return typeof window !== 'undefined';
}

/**
 * Support conditional redirecting, both server-side and client-side.
 *
 * Client-side, we can use next/router. But that doesn't exist on the server.
 * So on the server we must do an HTTP redirect. This component handles
 * the logic to detect whether on the server and client and redirect
 * appropriately.
 *
 * @param WrappedComponent The component that this functionality
 * will be added to.
 * @param clientCondition A function that returns a boolean representing
 * whether to perform the redirect. It will always be called, even on
 * the server. This is necessary so that it can have hooks in it (since
 * can't be inside conditionals and must always be called).
 * @param serverCondition A function that returns a boolean representing
 * whether to perform the redirect. It is only called on the server. It
 * accepts a Next page context as a parameter so that the request can
 * be examined and the response can be changed.
 * @param location The location to redirect to.
 */
export default function withConditionalRedirect({
  WrappedComponent,
  clientCondition,
  serverCondition,
  location
}) {
  const WithConditionalRedirectWrapper = props => {
    const router = useRouter();
    const redirectCondition = clientCondition();
    if (isBrowser() && redirectCondition) {
      router.push(location);
      return <></>;
    }
    return <WrappedComponent {...props} />;
  };

  WithConditionalRedirectWrapper.getInitialProps = async (ctx) => {
    if (!isBrowser() && ctx.res) {
      if (serverCondition(ctx)) {
        ctx.res.writeHead(302, { Location: location });
        ctx.res.end();
      }
    }

    const componentProps =
      WrappedComponent.getInitialProps &&
      (await WrappedComponent.getInitialProps(ctx));

    return { ...componentProps };
  };

  return WithConditionalRedirectWrapper;
}

哇,这真是出乎意料的复杂。我们必须同时考虑客户端和服务器端的重定向(不幸的是,它们的执行方式截然不同)。幸好我们把这些都放在了一起,而不是在两个 HOC 中重复代码。

有关 Next.js 中重定向的更多详细信息,请阅读Next.js 中的客户端端和服务器端重定向

现在让我们看看最终的 auth HOC 是什么样子的。

import { useIsAuthenticated } from '../providers/Auth';
import withConditionalRedirect from './withConditionalRedirect';

/**
 * Require the user to be authenticated in order to render the component.
 * If the user isn't authenticated, forward to the given URL.
 */
export default function withAuth(WrappedComponent, location='/signin') {
  return withConditionalRedirect({
    WrappedComponent,
    location,
    clientCondition: function withAuthClientCondition() {
      return !useIsAuthenticated();
    },
    serverCondition: function withAuthServerCondition(ctx) {
      return !ctx.req?.cookies.session;
    }
  });
}
import { useIsAuthenticated } from '../providers/Auth';
import withConditionalRedirect from './withConditionalRedirect';

/**
 * Require the user to be unauthenticated in order to render the component.
 * If the user is authenticated, forward to the given URL.
 */
export default function withoutAuth(WrappedComponent, location='/home') {
  return withConditionalRedirect({
    WrappedComponent,
    location,
    clientCondition: function withoutAuthClientCondition() {
      return useIsAuthenticated();
    },
    serverCondition: function withoutAuthServerCondition(ctx) {
      return !!ctx.req?.cookies.session;
    }
  });
}

您可以在示例应用程序中看到此架构的实现。它也可以在TypeScript中使用。

这里解释的架构仅支持 SSR。如果您需要支持静态优化,请阅读我的后续文章

链接地址:https://dev.to/justincy/detecting-authentication-client-side-in-next-js-with-an-httponly-cookie-when-using-ssr-4d3e
PREV
重构——哎呀,我一直做错了。
NEXT
如何在职业中成长:提示:这与技术无关。1. 学会估算和计划 2. 拥有超越预期的心态 3. 能够说服他人追随你的目标 4. 学会观察周围环境,保持专业精神 5. 保持高度的耐心 6. 洞察力比你想象的更重要 7. 不仅仅指导初级员工