前言

最近项目中使用SSR框架next.js,过程中会遇到token存储,状态管理等一系列问题,现在总结并记录下来分享给大家。

这里有一篇文章:NextJS SSR - JWT (Access/Refresh Token) Authentication with external Backend,文章对应的代码代码地址,使用typescript编写的,非常好的文章,大家有兴趣可以看看。

Token存储

SSR和SPA最大的区别就是SSR会区分客户端Client和服务端Server,并且SSR之间只能通过cookie才能在Client和Server之间通信,例如:token信息,以往我们在SPA项目中是使用localStorage或者sessionStorage来存储,但是在SSR项目中Server端是拿不到的,因为它是浏览器的属性,要想客户端和服务端同时都能拿到我们可以使用Cookie,所以token信息只能存储到Cookie中。

那么我们选用什么插件来设置和读取Cookie信息呢?插件也有好多种,比如:

但是它们有个最大的问题就是需要手动去控制读取和设置,有没有一种插件或者中间件自动获取和设置token呢?答案是肯定的,就是接下来我们要用到的next-redux-cookie-wrapper插件,这个插件的作用就是将reducer里面的数据自动储存到cookie中,然后组件获取reducer中的数据会自动从cookie中拿,它是next-redux-wrapper插件推荐的,而next-redux-wrapper插件是连接redux中store数据的插件,接下来会讲到。

数据持久化

SSR项目我们不建议数据做持久化,除了上面的token以及用户名等数据量小的数据需要持久化外,其它的都应该从后台接口返回,否则就失去了使用SSR的目的(直接从服务端返回带有数据的html)了,还不如去使用SPA来得直接。

状态管理

如果你的项目不是很大,且组件不是很多,你完全不用考虑状态管理,只有当组件数量很多且数据不断变化的情况下你需要考虑状态管理。

我们知道Next.js也是基于React,所以基于React的状态管理器同样适用于Next.js,比较流行的状态管理有:

这里有一篇文章专门介绍对比它们的,大家可以看看哪种比较适合自己。

最后我们选用的是redux的轻量级版本:redux-toolkit

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

集成状态管理器Redux及共享Token信息

首先我们先创建next.js项目,创建完之后,我们执行下面几个步骤来一步步实现集成。

  1. 创建store/axios.js文件
  2. 修改pages/_app.js文件
  3. 创建store/index.js文件
  4. 创建store/slice/auth.js文件

0. 创建store/axios.js文件

创建axios.js文件目的是为了统一管理axios,方便slice中axios的设置和获取。

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、修改pages/_app.js文件

使用next-redux-wrapper插件将redux store数据注入到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、创建store/index.js文件

  1. 使用@reduxjs/toolkit集成reducer并创建store,
  2. 使用next-redux-wrapper连接next.js和redux,
  3. 使用next-redux-cookie-wrapper注册要共享到cookie的slice信息。

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. 创建store/slice/auth.js文件

创建slice,通过axios调用后台接口返回token和user信息并保存到reducer数据中,上面的nextReduxCookieMiddleware会自动设置和读取这里的token和me及isLogin信息。

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;

这样就完成了所有插件的集成,接着我们运行网页,登录输入用户名密码,你会发现上面的数据都以密码的形式保存在Cookie中。

登录页面代码:

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;

注意

1、使用了next-redux-wrapper一定要加HYDRATE,目的是同步服务端和客户端reducer数据,否则两个端数据不一致造成冲突

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

2、注意next-redux-wrappernext-redux-cookie-wrapper版本

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

总结

1、ssr项目不要用持久化,而是直接从server端请求接口拿数据直接渲染,否则失去使用SSR的意义了,
2、Next.js分为静态渲染和服务端渲染,其实SSR项目如果你的项目很小,或者都是静态数据可以考虑直接使用客户端静态方法getStaticProps来渲染。

--- 2021-12-07 更新 ---

1、退出登录时如何处理?

退出登录时要考虑两步:1、清空reducer,2、清空cookie(可选)
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;
}

上面代码是清空reducer里面的登录信息:accessToken,me和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);
}
}}

这段代码是点击退出的回调时清空cookie里面的数据。

2、access_token过期如何处理?

解决思路:

这种方式其实就是使用oauth2协议的refresh_token重新获取access_token和refresh_token的方式,如果有同学不知道可以百度,下面开始讲解如何整合!

修改store/index.js文件

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

修改slices/authSlices.js文件

// 使用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;
},

上面的代码refreshToken方法使用就是从后台返回最新的access_token和refresh_token替换老的。

2.2.3 修改axios.js文件

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;

axios-auth-refresh插件是自动判断access token是否过期,有兴趣的可以看看实现代码,通过判断401来拦截的,当然你也可以自己手写axios.interceptors.response.use()来做判断!

上面的dispatch(refreshToken())使用就是调用reducer里面的refreshToken方法目的就是上面讲的,这样就完成了token过期后用户在无感知的情况下重新获取并替换,不用重新跳转到登录页面,达到提升用户体验!

解决refresh_token也过期问题:
最好设置refresh_token时间长点比如30天或者60天,给用户缓冲时间,一旦超过设置时间就让用户重新登录吧!

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

待续!!!

参考:redux-refresh-token-axios

--- 2021-12-24 更新 ---

解决上面的access_token的时候出现过好多问题,最终还是解决了,不过在解决过程中也发现有其它的解决方案供大家参考!
1、redux-toolkit refresh token
2、next-auth refresh token

--- 2022-01-28 更新 ---

使用上面的store/index.js配合next-redux-cookie-wrapper插件出现了新问题,就是不同的用户之间cookie会出现相互影响的问题,于是到插件所在的github上面提issue于是作者帮我解决了,但是没有完全解决:就是token过期401通过refresh token获取access token出现了问题。于是自己通过中间件解决了这个困扰数月的问题。
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);
}

引用

redux-toolkit

next-redux-cookie-wrapper

next-redux-wrapper

nextjs-auth

Next.js DEMO next-with-redux-toolkit

03-05 15:27