Server Side Rendering 常見認證問題 - Unexpected shared state between different users

歷經一番苦心處理 User Login Flow,配合 UX Designer 將需求實作出來,同時完成了整合 Nextjs 的複雜設定完美的解決 SEO 需求與提升初始畫面速度、通過 QA 層層打磨,你非常確信這次上線的系統不會有什麼節外生枝的問題在心中放了 120 顆心決定好好放鬆一下近期來的疲憊。

「為什麼我會突然什麼事情都沒做就被登出?」

「嗯?使用者資訊顯示的人不是我啊?」

收到 PM 回報的 issue,你乾瞪著眼重複確認 code 的 credential 認證沒問題,axios 很聰明的將 JWT 在 interceptor 夾上 Header 送出,看似萬無一失卻又無法抵擋來自 end user 的怒火。


理應是不同的 session 卻發生資料錯置、state 共用的問題是前端工程利用 Nextjs 或是 Nuxtjs 實作 Server Side Rendering,特別是想要在 Render 前先準備好資料時常誤觸的禁區。

我們試著來拆解上面情境一般來說的實作步驟 ↓

  1. 使用者登入成功,將 API 回傳回來的 JWT 存在 Browser 的 cache (可能是 Cookie 或是 LocalStorage 等等)
  2. axios 實作 interceptor,不管有 Credentail 或是沒有都會在每次 request 前夾入 Header 免得每次需要 Authentication 的 API call 都得自己夾上 Credential

這段簡單的實作在 Browser 端完全沒有問題,但如果想要更進一步,為了讓 Render 的時候不會閃一下 (因為一開始 intial 的時候沒有 user 資料),選擇在 Server Side 先執行 API call,我們稍微改一下上面的範例。

單純地將 API call 往上拉到 Server Side 完成又沒有調整太多實作邏輯,看起來萬無一失且有效地達成目標,但自從將這個改動部署上去之後就時不時收到有用戶會看到其他人名字的 Bug 回報。

「當開始使用 Server Side 準備資料,事情就會變得遠比只在 Browser 執行複雜」

為什麼會發生隨機的 User 狀態錯置問題呢?因為 Server 與 Browser 的記憶體本位不同。


從 Browser fetch user data 都是分別獨立在 Client 端

使用 Nextjs getServerSideProps 則是在集中在 Server Side 統一處理 (廢話)

詳細一點拆分步驟來看 ↓

問題就出在 Singleton at Server Side

Home.getServerSideProps = async ({ req }) => {
  // Set shared cookie for 
  Cookies.shared = Cookies.new(req.headers.cookie)

  const { data } = await axios.get('https://mock.bugfree.app/api/me')
  return {
    props: {
      user: data,
    },
  }
}

-----

axios.interceptors.request.use(config => {
  config.headers = {
    authorization: `Bearer ${Cookies.shared.get('user-token')}`,
    ...config.headers,
  }

  return config
})

當 Request 進到 getServerSideProps,我們取代/準備了 Cookies.shared 到用的期間只要運氣夠好,axios 都有機會用到不同 Request 所對應的 Cookies.shared

知道了問題點,調整的方式其實就非常簡單,每一次的 axios call 都一定需要重新夾 Credential 送出 ↓

Home.getServerSideProps = async ({ req }) => {
  // Set shared cookie for 
  const userToken = Cookies.new(req.headers.cookie).get('user-token')

  const { data } = await axios.get(
    'https://mock.bugfree.app/api/me',
    {
      headers: {
        Authroization: `Bearer ${userToken}`
      }
    },
  )
  return {
    props: {
      user: data,
    },
  }
}

上述的以 axios 當做例子所提到的誤區,需要擴充到包含狀態的所有實作,當然這包含了 redux。

next-redux-wrapper 就是以每個 request 當作單位來準備 redux store,確保 Server Side 不會混淆

// https://github.com/kirill-konshin/next-redux-wrapper/blob/master/packages/wrapper/src/index.tsx#L47
...
if (getIsServer()) {
    const c = context as any;
    let req;
    if (c.req) req = c.req;
    if (c.ctx && c.ctx.req) req = c.ctx.req;
    if (req) {
        // ATTENTION! THIS IS INTERNAL, DO NOT ACCESS DIRECTLY ANYWHERE ELSE
        // @see https://github.com/kirill-konshin/next-redux-wrapper/pull/196#issuecomment-611673546
        if (!req.__nextReduxWrapperStore) req.__nextReduxWrapperStore = createStore();
        return req.__nextReduxWrapperStore;
    }
    return createStore();
}
...

對比常見的在 Client 不會有問題但在 Server Side 會有機率出錯的問題寫法

let store

export cosnt withRedux = () => {
  store = createStore()
}

export store

到此,我們提出了一個解決方法,卻提升了實作上的冗贅,一開始的 axios interceoptor 在使用時一率都將自動夾上 Credential,如果要沿用這樣的設計,不論如何 axios module 都會在 Nextjs 的 Lifecycle 外,沒辦法達成每一個 Request 都分離的需求。

相對 Nextjs 來說,Nuxtjs 的解決方案提供的相對齊全,使用整合的 $axios 就不必擔心 by request 的問題。

// https://axios.nuxtjs.org/usage
async asyncData({ $axios }) {
  const ip = await $axios.$get('http://icanhazip.com')
  return { ip }
}

-----

methods: {
  async fetchSomething() {
    const ip = await this.$axios.$get('http://icanhazip.com')
    this.ip = ip
  }
}

現階段而言,對 React 的設計原則來說,不應該學 Vue 將 axios instance 夾在 props 裡面傳遞,所以在不考慮任何 side effect 解決方案 (redux-observableredux-saga) 的整合方案來說,最簡單的處理方式如下 ↓

⚠️ 但需要非常小心使用,只有 Client Side 才能用 default,Server Side 得每次都手動賦予 Credential。


總結:當需要 Server Side Rendering 特別是處理 state 相關的處理的時候,都需要非常小心避免使用 Singleton。

另外,上面所提的解決方案只有 axios 的簡單解法,如果是需要整合 redux-observableredux-saga 的話,這又是一段艱辛的路程了...