4

foreword

Recently, the SSR framework next.js is used in the project, and a series of problems such as token storage and state management will be encountered in the process. Now I summarize and record it and share it with you.

Here is an article: NextJS SSR - JWT (Access/Refresh Token) Authentication with external Backend , the code address corresponding to the article, written in typescript, is a very good article, you can take a look if you are interested.

Token storage

The biggest difference between SSR and SPA is that SSR will distinguish the client client from the server server, and the SSR can only communicate between the client and the server through cookies. For example: token information, we used to use in the SPA localStorage or sessionStorage to store, but in the SSR project, the server side cannot get it, because it is an attribute of the browser, if the client and the server can get us at the same time Cookies can be used, so token information can only be stored in cookies.

So what plug-in do we use to set and read cookie information? There are also many kinds of plugins, such as:

But their biggest problem is that they need to manually control reading and setting. Is there a plugin or middleware to automatically obtain and set tokens? The answer is yes, it is the next-redux-cookie-wrapper plug-in that we will use next. The function of this plug-in is to automatically store the data in the reducer into the cookie, and then the component will automatically get the data in the reducer from the cookie. Take it, it is recommended by the next-redux-wrapper plugin, and the next-redux-wrapper plugin is a plugin that connects to the store data in redux, which will be discussed next.

data persistence

We do not recommend data persistence in the SSR project. Except for the small data such as the above token and user name that need to be persisted, all other data should be returned from the background interface, otherwise the purpose of using SSR will be lost (directly from the server Return html with data), it is better to use SPA directly.

state management

If your project is not very large, and there are not many components, you don't need to consider state management at all, only when the number of components is large and the data is constantly changing, you need to consider state management.

We know that Next.js is also based on React, so the state manager based on React is also applicable to Next.js. The more popular state management are:

Here is an article dedicated to comparing them, you can see which one is more suitable for you.

In the end, we chose the lightweight version of redux: redux-toolkit .

下面我们会集成redux-toolkit cookie next-redux-cookie-wrapper以及连接next.js服务端与redux store数据通信方法getServerSideProps next-redux-wrapper .

Integrated state manager Redux and shared Token information

First, we create the next.js project. After the creation, we perform the following steps to realize the integration step by step.

  1. Create store/axios.js file
  2. Modify pages/_app.js file
  3. Create store/index.js file
  4. Create store/slice/auth.js file

0. Create store/axios.js file

The purpose of creating the axios.js file is to manage axios in a unified manner, and to facilitate the setting and acquisition of axios in slices.

store/axios.js

 import axios from 'axios';
import createAuthRefreshInterceptor from 'axios-auth-refresh';
import * as cookie from 'cookie';
import * as setCookie from 'set-cookie-parser';
// Create axios instance.
const axiosInstance = axios.create({
  baseURL: `${process.env.NEXT_PUBLIC_API_HOST}`,
  withCredentials: false,
});
export default axiosInstance;

1. Modify the pages/_app.js file

Use the next-redux-wrapper plugin to inject redux store data into next.js.

pages/_app.js

 import {Provider} from 'react-redux'
import {store, wrapper} from '@/store'

const MyApp = ({Component, pageProps}) => {
  return <Component {...pageProps} />
}

export default wrapper.withRedux(MyApp)

2. Create the store/index.js file

  1. Use @reduxjs/toolkit integrate reducer and create store,
  2. Use next-redux-wrapper to connect next.js and redux,
  3. Use next-redux-cookie-wrapper to register the slice information to be shared to the cookie.

store/index.js

 import {configureStore, combineReducers} from '@reduxjs/toolkit';
import {createWrapper} from 'next-redux-wrapper';
import {nextReduxCookieMiddleware, wrapMakeStore} from "next-redux-cookie-wrapper";
import {authSlice} from './slices/auth';
import logger from "redux-logger";

const combinedReducers = combineReducers({
  [authSlice.name]: authSlice.reducer
});
export const store = wrapMakeStore(() => configureStore({
  reducer: combinedReducers,
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().prepend(
      nextReduxCookieMiddleware({
        // 在这里设置你想在客户端和服务器端共享的cookie数据,我设置了下面三个数据,大家依照自己的需求来设置就好
        subtrees: ["auth.accessToken", "auth.isLogin", "auth.me"],
      })
    ).concat(logger)
}));
const makeStore = () => store;
export const wrapper = createWrapper(store, {storeKey: 'key', debug: true});

3. Create store/slice/auth.js file

Create a slice, call the background interface through axios to return the token and user information and save it to the reducer data. The above nextReduxCookieMiddleware will automatically set and read the token, me and isLogin information here.

store/slice/auth.js

 import {createAsyncThunk, createSlice} from '@reduxjs/toolkit';
import axios from '../axios';
import qs from "qs";
import {HYDRATE} from 'next-redux-wrapper';

// 获取用户信息
export const fetchUser = createAsyncThunk('auth/me', async (_, thunkAPI) => {
  try {
    const response = await axios.get('/account/me');
    return response.data.name;
  } catch (error) {
    return thunkAPI.rejectWithValue({errorMsg: error.message});
  }
});

// 登录
export const login = createAsyncThunk('auth/login', async (credentials, thunkAPI) => {
  try {
  
    // 获取token信息
    const response = await axios.post('/auth/oauth/token', qs.stringify(credentials));
    const resdata = response.data;
    if (resdata.access_token) {
      // 获取用户信息
      const refetch = await axios.get('/account/me', {
        headers: {Authorization: `Bearer ${resdata.access_token}`},
      });
      
      return {
        accessToken: resdata.access_token,
        isLogin: true,
        me: {name: refetch.data.name}
      };
    } else {
      return thunkAPI.rejectWithValue({errorMsg: response.data.message});
    }

  } catch (error) {
    return thunkAPI.rejectWithValue({errorMsg: error.message});
  }
});

// 初始化数据
const internalInitialState = {
  accessToken: null,
  me: null,
  errorMsg: null,
  isLogin: false
};

// reducer
export const authSlice = createSlice({
  name: 'auth',
  initialState: internalInitialState,
  reducers: {
    updateAuth(state, action) {
      state.accessToken = action.payload.accessToken;
      state.me = action.payload.me;
    },
    reset: () => internalInitialState,
  },
  extraReducers: {
    // 水合,拿到服务器端的reducer注入到客户端的reducer,达到数据统一的目的
    [HYDRATE]: (state, action) => {
      console.log('HYDRATE', state, action.payload);
      return Object.assign({}, state, {...action.payload.auth});
    },
    [login.fulfilled]: (state, action) => {
      state.accessToken = action.payload.accessToken;
      state.isLogin = action.payload.isLogin;
      state.me = action.payload.me;
    },
    [login.rejected]: (state, action) => {
      console.log('action=>', action)
      state = Object.assign(Object.assign({}, internalInitialState), {errorMsg: action.payload.errorMsg});
      console.log('state=>', state)
      // throw new Error(action.error.message);
    },
    [fetchUser.rejected]: (state, action) => {
      state = Object.assign(Object.assign({}, internalInitialState), {errorMsg: action.errorMsg});
    },
    [fetchUser.fulfilled]: (state, action) => {
      state.me = action.payload;
    }
  }
});

export const {updateAuth, reset} = authSlice.actions;

This completes the integration of all plug-ins. Then we run the web page, log in and enter the username and password. You will find that the above data is saved in the cookie in the form of a password.

image.png

Login page code:

pages/login.js

 import React, {useState, useEffect} from "react";
import {Form, Input, Button, Checkbox, message, Alert, Typography} from "antd";
import Record from "../../components/layout/record";
import styles from "./index.module.scss";
import {useRouter} from "next/router";
import {useSelector, useDispatch} from 'react-redux'
import {login} from '@/store/slices/auth';
import {wrapper} from '@/store'


const {Text, Link} = Typography;
const layout = {
  labelCol: {span: 24},
  wrapperCol: {span: 24}
};
const Login = props => {
  const dispatch = useDispatch();
  const router = useRouter();
  const [isLoding, setIsLoading] = useState(false);
  const [error, setError] = useState({
    show: false,
    content: ""
  });

  function closeError() {
    setError({
      show: false,
      content: ""
    });
  }

  const onFinish = async ({username, password}) => {
    if (!username) {
      setError({
        show: true,
        content: "请输入用户名"
      });
      return;
    }
    if (!password) {
      setError({
        show: true,
        content: "请输入密码"
      });
      return;
    }
    setIsLoading(true);
    let res = await dispatch(login({
      grant_type: "password",
      username,
      password
    }));
    if (res.payload.errorMsg) {
      message.warning(res.payload.errorMsg);
    } else {
      router.push("/");
    }
    setIsLoading(false);
  };

  function render() {
    return props.isLogin ? (
      <></>
    ) : (
      <div className={styles.container}>
        <div className={styles.content}>
          <div className={styles.card}>
            <div className={styles.cardBody}>
              <div className={styles.error}>{error.show ?
                <Alert message={error.content} type="error" closable afterClose={closeError}/> : null}</div>
              <div className={styles.cardContent}>
                <Form
                  {...layout}
                  name="basic"
                  initialValues={{remember: true}}
                  layout="vertical"
                  onFinish={onFinish}
                  // onFinishFailed={onFinishFailed}
                >
                  <div className={styles.formlabel}>
                    <b>用户名或邮箱</b>
                  </div>
                  <Form.Item name="username">
                    <Input size="large"/>
                  </Form.Item>
                  <div className={styles.formlabel}>
                    <b>密码</b>
                    <Link href="/account/password_reset" target="_blank">
                      忘记密码
                    </Link>
                  </div>
                  <Form.Item name="password">
                    <Input.Password size="large"/>
                  </Form.Item>

                  <Form.Item>
                    <Button type="primary" htmlType="submit" block size="large" className="submit" loading={isLoding}>
                      {isLoding ? "正在登录..." : "登录"}
                    </Button>
                  </Form.Item>
                </Form>
                <div className={styles.newaccount}>
                  首次使用Seaurl?{" "}
                  <Link href="/join?ref=register" target="_blank">
                    创建一个账号
                  </Link>
                  {/* <a className="login-form-forgot" href="" >
                                    创建一个账号</a> */}
                </div>
              </div>
            </div>

            <div className={styles.recordWrapper}>
              <Record/>
            </div>
          </div>
        </div>
      </div>
    );
  }

  return render();
};

export const getServerSideProps = wrapper.getServerSideProps(store => ({ctx}) => {
  const {isLogin, me} = store.getState().auth;
  if(isLogin){
    return {
      redirect: {
        destination: '/',
        permanent: false,
      },
    }
  }
  return {
    props: {}
  };
});

export default Login;

Notice

1. Use next-redux-wrapper must add HYDRATE, the purpose is to synchronize the server and client reducer data, otherwise the data of the two ends will be inconsistent and cause conflict

 [HYDRATE]: (state, action) => {
      console.log('HYDRATE', state, action.payload);
      return Object.assign({}, state, {...action.payload.auth});
    },

2. Note next-redux-wrapper and next-redux-cookie-wrapper versions

 "next-redux-cookie-wrapper": "^2.0.1",
"next-redux-wrapper": "^7.0.2",

Summarize

1. Do not use persistence for ssr projects, but directly render data from the server-side request interface, otherwise it will lose the meaning of using SSR.
2. Next.js is divided into static rendering and server-side rendering. In fact, if your SSR project is small, or all static data, you can consider directly using the client-side static method getStaticProps to render.

--- Updated 2021-12-07---

1. What should I do when I log out?

There are two steps to consider when logging out: 1. Clear the reducer, 2. Clear the cookie (optional)
authSlice.js

 // 退出登录
export const logout = createAsyncThunk('auth/logout', async (_, thunkAPI) => {
  try {
    return true
  } catch (error) {
    return thunkAPI.rejectWithValue({errorMsg: error.message});
  }
});

[logout.fulfilled]: (state, action) => {
    state.accessToken = null;
    state.refreshToken = null;
    state.me = null;
    state.isLogin = false;
}

The above code is to clear the login information in the reducer: accessToken, me and isLogin

xxx.js

 onClick={async (item, key, keyPath, domEvent) => {
if (item.key === "logout") {
  const res = await dispatch(logout())
  if (res.payload) {
    // 退出修改reducer之后,要清除cookie
    Cookies.remove('auth.me') // fail!
    Cookies.remove('auth.isLogin') // fail!
    Cookies.remove('auth.accessToken') // fail!
  }
  router.push("/login");
} else {
  router.push(item.key);
}
}}

This code is to clear the data in the cookie when the callback to exit is clicked.

2. How to deal with the expired access_token?

Solutions:

Use refresh_token to get access_token again

This method is actually a method of using the refresh_token of the oauth2 protocol to re-obtain access_token and refresh_token. If some students don't know they can use Baidu, let's start to explain how to integrate them!

Modify the store/index.js file

 const combinedReducers = combineReducers({
  [authSlice.name]: authSlice.reducer,
  [layoutSlice.name]: layoutSlice.reducer,
  [systemSlice.name]: systemSlice.reducer,
  [spaceSlice.name]: spaceSlice.reducer,
  [settingSlice.name]: settingSlice.reducer,
  [userSlice.name]: userSlice.reducer,
  [homeSlice.name]: homeSlice.reducer
});
export const initStore = configureStore({
  reducer: combinedReducers,
  middleware: (getDefaultMiddleware) => getDefaultMiddleware().prepend(
    nextReduxCookieMiddleware({
      // 是否压缩
      // compress: false,
      subtrees: ["auth.accessToken", "auth.refreshToken", "auth.isLogin", "auth.me"],
    })
  ).concat(logger)
})
export const store = wrapMakeStore(() => initStore);


export const wrapp

Modify slices/authSlices.js file

 // 使用refresh token 获取 access token
export const refreshToken = createAsyncThunk('auth/refreshToken', async (params, thunkAPI) => {
  try {
    const {refreshToken} = thunkAPI.getState().auth;
    const response = await axios.post('/auth/oauth/token', qs.stringify({
      grant_type: 'refresh_token',
      refresh_token: refreshToken
    }));
    const resdata = response.data;
    if (resdata.access_token) {
      const refetch = await axios.get('/account/me', {
        headers: {Authorization: `Bearer ${resdata.access_token}`},
      });
      return {
        accessToken: resdata.access_token,
        refreshToken: resdata.refresh_token,
        isLogin: true,
        me: {
          name: refetch.data.name,
          avatar: refetch.data.avatar
        }
      };
    } else {
      return thunkAPI.rejectWithValue({errorMsg: response.data.message});
    }

  } catch (error) {
    return thunkAPI.rejectWithValue({errorMsg: error.message});
  }
});

// 初始化数据
const internalInitialState = {
  accessToken: null,
+ refreshToken: null,
  me: null,
  errorMsg: null,
  isLogin: false
};


[refreshToken.fulfilled]: (state, action) => {
  state.accessToken = action.payload.accessToken;
  state.refreshToken = action.payload.refreshToken;
  state.isLogin = action.payload.isLogin;
  state.me = action.payload.me;
},

The above code refreshToken method is to return the latest access_token and refresh_token from the background to replace the old ones.

2.2.3 Modify the axios.js file

 const axiosInstance = axios.create({
  baseURL: `${process.env.NEXT_PUBLIC_API_HOST}`,
  withCredentials: false,
});

// refresh token when 401
createAuthRefreshInterceptor(axiosInstance, async failedRequest => {
  const {dispatch} = initStore;
  const res = await dispatch(refreshToken());
  console.log('============createAuthRefreshInterceptor callback=======', res.payload.accessToken)
  failedRequest.response.config.headers.Authorization = 'Bearer ' + res.payload.accessToken;
  return Promise.resolve();
});

export default axiosInstance;

The axios-auth-refresh plugin automatically determines whether the access token has expired. If you are interested, you can look at the implementation code and intercept it by judging 401. Of course, you can also write by yourself axios.interceptors.response.use() to make a judgment!

The above dispatch(refreshToken()) use is to call the refreshToken method in the reducer. The purpose is as mentioned above, so that the user can re-acquire and replace the token after it expires without being aware of it, and there is no need to jump to the login page again. To improve the user experience!

Solve the problem that refresh_token also expires:
It is best to set the refresh_token time to be longer, such as 30 days or 60 days, to give users a buffer time. Once the set time is exceeded, let the user log in again!

 oAuth2.0中access_token默认有效时长为12个小时,refresh_token默认时长为30天

to be continued! ! !

Reference: redux-refresh-token-axios

--- Updated 2021-12-24---

There have been many problems when solving the above access_token, and finally solved, but in the process of solving, we also found other solutions for your reference!
1. redux-toolkit refresh token
2. next-auth refresh token

---Updated on 2022-01-28---

Using the above store/index.js with next-redux-cookie-wrapper the plugin has a new problem, that is, the cookie between different users will interact with each other , so I went to the github where the plugin is located to raise an issue and the author helped I solved it, but it is not completely solved: the token expires 401 and there is a problem with obtaining the access token through the refresh token. So I solved this problem that had been bothering me for months through middleware.
store/index.js

 import {configureStore, combineReducers} from '@reduxjs/toolkit';
import {createWrapper, HYDRATE} from 'next-redux-wrapper';
import {nextReduxCookieMiddleware, wrapMakeStore} from "next-redux-cookie-wrapper";
import logger from "redux-logger";
import {authSlice} from './slices/authSlice';
import {layoutSlice} from './slices/layoutSlice';
import {systemSlice} from './slices/systemSlice';
import {settingSlice} from "./slices/settingSlice";
import {spaceSlice} from "./slices/spaceSlice";
import {userSlice} from "./slices/userSlice";
import {homeSlice} from "./slices/homeSlice";
import {notifySlice} from "./slices/notifySlice";
import {axiosMiddleware} from '@/middleware/axiosMiddleware'


const combinedReducers = combineReducers({
  [authSlice.name]: authSlice.reducer,
  [layoutSlice.name]: layoutSlice.reducer,
  [systemSlice.name]: systemSlice.reducer,
  [spaceSlice.name]: spaceSlice.reducer,
  [settingSlice.name]: settingSlice.reducer,
  [userSlice.name]: userSlice.reducer,
  [homeSlice.name]: homeSlice.reducer,
  [notifySlice.name]: notifySlice.reducer
});


const rootReducer = (state, action) => {
  if (action.type === HYDRATE) {
    const nextState = {
      ...state, // use previous state
      ...action.payload, // apply delta from hydration
    }
    return nextState
  }
  return combinedReducers(state, action)
}

export const initStore = () => configureStore({
  reducer: rootReducer,
  middleware: (getDefaultMiddleware) => getDefaultMiddleware().prepend(
    nextReduxCookieMiddleware({
      // 是否压缩
      // compress: false,
      subtrees: ["auth.accessToken", "auth.refreshToken", "auth.isLogin", "auth.me"],
    })
  ).concat(axiosMiddleware).concat(process.env.NODE_ENV === `development` ? logger : (store) => (next) => (action) => {
    //自定义中间件作用:如果上面的判断不返回则会报错,所以返回了一个空的自定义中间件
    return next(action);
  })
})

export const store = wrapMakeStore(initStore);
export const wrapper = createWrapper(store, {storeKey: 'key', debug: process.env.NODE_ENV === `development`});

axiosMiddleware.js

 import createAuthRefreshInterceptor from "axios-auth-refresh";
import axiosInstance from "@/store/axios";
import {refreshToken} from '@/store/slices/authSlice';

export const axiosMiddleware = (store) => (next) => (action) => {
  // refresh token when 401
  createAuthRefreshInterceptor(axiosInstance, async failedRequest => {
    const res = await store.dispatch(refreshToken());
    console.log('============createAuthRefreshInterceptor callback=======', res.payload)
    if (res.payload && res.payload.accessToken)
      failedRequest.response.config.headers.Authorization = 'Bearer ' + res.payload.accessToken;
    return Promise.resolve();
  });
  //自定义中间件作用:如果上面的判断不返回则会报错,所以返回了一个空的自定义中间件
  return next(action);
}

Summarize

1. 10,000 attempts were successful
2. Careful success

quote

redux-toolkit

next-redux-cookie-wrapper

next-redux-wrapper

nextjs-auth

Next.js DEMO next-with-redux-toolkit


Awbeci
3.1k 声望213 粉丝

Awbeci