Next.js/Node.js(Express):设置Cookie(使用httpOnly)位于响应标头中,但不在浏览器存储中

9nvpjoqh  于 2023-06-22  发布在  Node.js
关注(0)|答案(1)|浏览(173)

服务器:Node.js,express,Type-Graphql with Apollo Server

index.ts中:

import 'reflect-metadata';
import express from 'express';
import { ApolloServer } from 'apollo-server-express';
import { buildSchema } from 'type-graphql';
import { createConnection } from 'typeorm';
import { verify } from 'jsonwebtoken';
import coockieParser from 'cookie-parser';
import cors from 'cors';
import User from './entity/User';
import UserResolver from './resolvers';
import { createAccessToken, createRefreshToken, sendRefreshToken } from './auth';

require('dotenv').config();

const corsOptions = {
  allowedHeaders: ['Origin', 'X-Requested-With', 'Content-Type', 'Accept', 'X-Access-Token', 'Authorization'],
  credentials: true, // this allows to send back (to client) cookies
  methods: 'GET,HEAD,OPTIONS,PUT,PATCH,POST,DELETE',
  origin: 'http://localhost:3000',
  preflightContinue: false,
};

(async () => {
  const PORT = process.env.PORT || 4000;
  const app = express();
  app.use(coockieParser());
  app.use(cors(corsOptions));

  // -- non graphql endpoints
  app.get('/', (_, res) => {
    res.send('Starter endpoint');
  });

  app.post('/refresh_token', async (req, res) => {
    const token = req.cookies.jid;

    if (!token) {
      return res.send({ ok: false, accessToken: '' });
    }

    let payload: any = null;
    try {
      payload = verify(token, process.env.REFRESH_TOKEN_SECRET!);
    } catch (e) {
      console.log(e);
      return res.send({ ok: false, accessToken: '' });
    }

    // token is valid, and the access token can be send back
    const user = await User.findOne({ id: payload.userId });

    if (!user) {
      return res.send({ ok: false, accessToken: '' });
    }

    if (user.tokenVersion !== payload.tokenVersion) {
      return res.send({ ok: false, accessToken: '' });
    }

    sendRefreshToken(res, createRefreshToken(user));

    return res.send({ ok: true, accessToken: createAccessToken(user) });
  });
  //--

  // -- db
  await createConnection();
  // --

  // -- apollo server settings
  const apolloServer = new ApolloServer({
    schema: await buildSchema({
      resolvers: [UserResolver],
    }),
    context: ({ req, res }) => ({ req, res }),
  });

  await apolloServer.start();

  apolloServer.applyMiddleware({
    app,
    cors: false,
  });
  // --

  app.listen(PORT, () => {
    console.log(`Server running on port: ${PORT}`);
  });
})();

UserResolver中的登录突变:

//..

@Mutation(() => LoginResponse)
  async login(
    @Arg('email') email: string,
    @Arg('password') password: string,
    @Ctx() { res }: AuthContext,
  ): Promise<LoginResponse> {
    const user = await User.findOne({ where: { email } });

    if (!user) {
      throw new Error('Incorrect email');
    }

    const valid = await compare(password, user.password);

    if (!valid) {
      throw new Error('Incorrect password');
    }

    sendRefreshToken(res, createRefreshToken(user));

    return {
      accessToken: createAccessToken(user),
      user,
    };
  }

//..

在处理身份验证时,cookie在响应头中设置如下:

//..

export const createAccessToken = (user: User) => sign({ userId: user.id }, process.env.ACCESS_TOKEN_SECRET!, { expiresIn: '10m' });

export const createRefreshToken = (user: User) => sign({ userId: user.id, tokenVersion: user.tokenVersion }, process.env.REFRESH_TOKEN_SECRET!, { expiresIn: '7d' });

export const sendRefreshToken = (res: Response, refreshToken: string) => {
  res.cookie('jid', refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    path: '/refresh_token',
  });
};

//..

客户端:Next.js,Graphql with URQL

_app.tsx中:

/* eslint-disable react/jsx-props-no-spreading */
import * as React from 'react';
import Head from 'next/head';
import { AppProps } from 'next/app';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import { CacheProvider, EmotionCache } from '@emotion/react';
import { createClient, Provider } from 'urql';
import theme from '../styles/theme';
import createEmotionCache from '../lib/createEmotionCache';
import '../styles/globals.css';

// Client-side cache shared for the whole session of the user in the browser.
const clientSideEmotionCache = createEmotionCache();

interface IAppProps extends AppProps {
  // eslint-disable-next-line react/require-default-props
  emotionCache?: EmotionCache;
}

const client = createClient({
  url: 'http://localhost:4000/graphql',
  fetchOptions: {
    credentials: 'include',
  },
});

const App = (props: IAppProps) => {
  const { Component, emotionCache = clientSideEmotionCache, pageProps } = props;
  return (
    <Provider value={client}>
      <CacheProvider value={emotionCache}>
        <Head>
          <title>Client App</title>
        </Head>
        <ThemeProvider theme={theme}>
          <CssBaseline />
          <Component {...pageProps} />
        </ThemeProvider>
      </CacheProvider>
    </Provider>
  );
};

export default App;

登录页面不依赖于SSR或SSG(因此是CSR):

import React from 'react';
import LoginForm from '../components/LoginForm/LoginForm';
import Layout from '../layouts/Layout';

interface ILoginProps {}

const Login: React.FC<ILoginProps> = () => (
  <Layout
    showNavbar={false}
    showTransition={false}
    maxWidth='xs'
  >
    <LoginForm />
  </Layout>
);

export default Login;

在LoginForm组件中使用该变化来请求访问令牌并在浏览器cookie中设置刷新令牌:

import React from 'react';
import { useRouter } from 'next/router';
import { useLoginMutation } from '../../generated/graphql';
//...

const LoginForm = () => {
  //..
  
  const [, login] = useLoginMutation();
  const router = useRouter();

  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();

    if (disabledSubmit) {
      setShowFormHelper(true);
    } else {
      const res = await login({
        email, // from the state of the component
        password,
      });

      if (res && res.data?.login) {
        console.log(res.data.login.accessToken);
        router.push('/home');
        setShowFormHelper(false);
      } else {
        setHelper('Something went wrong');
      }
    }
  };

//..
};

export default LoginForm;

问题

所以,问题是登录响应在头文件中设置了-cookie,但浏览器中仍然没有设置cookie:

提问

之前,我已经使用相同的服务器代码实现了相同的身份验证方案,但客户端使用create-react-app。一切都很好。那么,* 为什么它现在不能用next.js呢?我错过了什么?*

解决方案

我可以使用类似cookies-next的东西来将饼干放入存储器中。然后需要在响应数据中传递刷新令牌:

import React from 'react';
import { useRouter } from 'next/router';
import { useLoginMutation } from '../../generated/graphql';
import { setCookies } from 'cookies-next';

//...

const LoginForm = () => {
  //..
  
  const [, login] = useLoginMutation();
  const router = useRouter();

  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();

    if (disabledSubmit) {
      setShowFormHelper(true);
    } else {
      const res = await login({
        email, // from the state of the component
        password,
      });

      if (res && res.data?.login) {
        console.log(res.data.login.accessToken);
        setCookies('jid', res.data.login.refreshToken);
        router.push('/home');
        setShowFormHelper(false);
      } else {
        setHelper('Something went wrong');
      }
    }
  };

//..
};

export default LoginForm;

setCookie接受选项。但是,在这种情况下,httpOnly不能设置为true

更新

事实证明,上面的一切都可以在Firefox中运行,但在Chrome中不行。

yizd12fk

yizd12fk1#

默认情况下,浏览器对跨域请求和cookie有不同的策略,特别是在使用Secure标志和SameSite属性设置cookie时,因此浏览器体验介于FirefoxChrome之间。
要解决登录响应的Set-Cookie标头未在浏览器中设置cookie的问题,请尝试修改服务器代码中的sendRefreshToken函数。
sendRefreshToken函数中,设置cookie时将res.cookie块中的sameSite属性从strict更改为lax。此更改可以解决问题:

export const sendRefreshToken = (res: Response, refreshToken: string) => {
  res.cookie('jid', refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    path: '/refresh_token',
  });
};

相关问题