Next.js實踐總結 - 登陸受權驗證最佳方案

最近作了幾個項目都是使用腳手架next-antd-scaffold來作的,在系統的開發過程當中,登陸受權以及路由鑑權這一塊,一直在琢磨與改進,但願能找到一個最優解,今天就把我的總結的幾個Next.js受權驗證方案來跟你們分享一下~前端

這裏來講一下爲啥是Next.js方案,由於SSR框架的不一樣,首屏渲染會在服務端進行,因此一些處理或者請求與普通的SPA就有一些區別,普通的客戶端渲染,登陸驗證只須要一套邏輯就能夠了,而在Next.js裏面服務端和客戶端其實須要單獨處理,所以,抽象出一套統一便利的解決方案,有助於咱們業務的開發~node

由於Nuxt與Next基本大同小異,而受權驗證又與代碼無關,邏輯設計層面的事情,因此我以爲應該是一種SSR框架的通用登陸受權方案了~git

登陸

登陸邏輯很簡單,也沒什麼可說的,不管是什麼系統,登陸模塊應該都是必不可少的,那麼我就來講一下我這邊開發過程當中遇到的一些問題和總結吧。通常來講,對於商業系統或者博客類系統,登陸有兩種場景。github

第一種:用戶第一次進入系統,那麼提供給用戶的就是登陸頁面,用戶登陸完進入系統;json

第二種,用戶登陸過系統,系統保存用戶的受權信息,在必定的時間內,不會再進行登陸,用戶進來直接進入系統首頁或者url頁面,當用戶受權信息失效或用戶清理了瀏覽器,會提示用戶從新登陸。redux

登陸成功以後咱們將用戶信息寫入指定位置(通常用戶相關信息放入state,用戶受權信息放入cookie),方便接下來進行受權驗證操做。後端

關於登陸的一個小問題

關於用戶受權信息過失效從新登陸,其實又分爲兩種狀況:第一種,用戶進入系統頁面(這裏不必定是首頁,多是任何頁面)以後發送該頁面的請求,發現用戶受權過時,此時提示用戶登陸失效,從新登陸;第二種,用戶進入系統在瀏覽器顯示頁面以前(也就是服務端的時候),就判斷出來用戶登陸失效了,此時重定向到登陸頁(不管用戶打開的是什麼頁面)。api

第一種場景我稱之爲閃現登陸(顧名思義,用戶進入系統以後會重定向到登陸頁,又一個一閃而過的頁面切換過程),第二種我稱爲無閃現登陸方案。並非說無閃現就必定比閃現的好,兩種方案在商業系統中應該都有被使用,具體看本身的業務場景~這裏也不打算細聊,只是寫到這了就簡單說說~瀏覽器

受權

受權,就是先後端關於接口的權限定義,接口是否能夠被全部人訪問,若是不是,先後端校驗的是何字段,放在什麼位置等等。每一個人每一個team都有本身的代碼習慣,這裏不強求全部人都按照個人習慣去寫,下面的方案只是本身的業務場景抽象,你們能夠根據本身習慣適當改進使用~bash

cookie + token

我這邊習慣是使用token進行先後端用戶的身份驗證,後臺生成token前端存入cookie,以後的請求前端將token從cookie中取出而後攜帶到請求的header(我這邊使用的是fetch)裏,分爲以下兩種場景:

用戶從未登陸過 -> 登陸邏輯

用戶第一次進入系統,進行登陸,用戶密碼驗證經過後,拿到相關信息和token並將其存入cookie內。

用戶登陸過 -> 前端受權邏輯

用戶登陸過,token存在且在有效期內,此時走auth流程,直接從cookie裏獲取用戶相關信息,無需發送請求。

爲何要分兩個場景呢?

由於用戶信息是存在state裏的,而當系統刷新的時候state會是初始狀態,其實大部分狀況是不必從新發送請求去跟後臺獲取數據的,相關的用戶信息(用戶名、用戶id等必要信息)咱們能夠存入cookie裏,而後在受權邏輯裏從cookie把用戶信息存入state就能夠了,節省了沒必要要的網絡請求。

token除了放入cookie,還能夠放state裏,不過放state可能會存在一個問題,就是token狀態可能不會及時同步,好比token過時時間是一小時,一小時後失效應該從新受權登陸,而存入state裏面會出現問題是若是我打開頁面登陸未關閉,那麼一個小時後state內的token是不會過時消失的,而你放在cookie裏能夠設置cookie的過時時間。固然那種場景過時了你從後臺的響應也能夠看出來,整體也沒什麼太大的影響。

受權方案

登陸成功以後的相關信息會存入cookie。這裏我用USER_TOKEN,USER_NAME和USER_ID表示用戶登陸成功寫入cookie的信息。

  • 在_app.js的getinitialProps內新增受權邏輯函數initialize(ctx)
static async getInitialProps ({ Component, ctx }) {
    let pageProps = {};
    /** 應用初始化, 必定要在Component.getInitiialProps前面
     *  由於裏面是受權,系統最優先的邏輯
     *  傳入的參數是ctx,裏面包含store和req等
     **/
    initialize(ctx);
    
    if (Component.getInitialProps) {
      pageProps = await Component.getInitialProps({ ctx });
    }
    return { pageProps };
  }
複製代碼
  • initialize邏輯
/**
 * 進入系統初始化函數,用於用戶受權相關
 * @param {Object} ctx
 */
export default function(ctx) {
  const { req, store } = ctx;
  const userToken = getCookie('USER_TOKEN', req);
  if (userToken && !store.getState().user.auth.user) {
    // cookie存在token而且auth.user不存在爲null,直接走auth流程便可,判斷user是否爲空是爲了不每次一路由跳轉都走auth流程
    const payload = {
      username: getCookie('USER_NAME', req),
      userId: getCookie('USER_ID', req),
    } // 獲取相關用戶信息存入state
    store.dispatch(
      authUserSuccess(payload)
    );
  }
}
複製代碼
  • 封裝Cookie

爲何要封裝cookie,這裏要說明一下,前端cookie咱們使用js-cookie去進行操做和處理,可是SSR框架存在服務端獲取數據的過程,服務端的時候咱們不能經過js-cookie去獲取,而是要經過咱們傳入的req來獲取,因此最後實現的服務端和客戶端都能獲取的封裝

import cookie from 'js-cookie';
/**
 * 基於js-cookie插件進行封裝
 * Client-Side -> 直接使用js-cookie API進行獲取
 * Server-Side -> 使用ctx.req進行獲取(req.headers.cookie)
 */
export const getCookie = (key, req) => {
  return process.browser
    ? getCookieFromBrowser(key)
    : getCookieFromServer(key, req);
};

const getCookieFromBrowser = key => {
  return cookie.get(key);
};

const getCookieFromServer = (key, req) => {
  if (!req.headers.cookie) {
    return undefined;
  }
  const rawCookie = req.headers.cookie
    .split(';')
    .find(c => c.trim().startsWith(`${key}=`));
  if (!rawCookie) {
    return undefined;
  }
  return rawCookie.split('=')[1];
};
複製代碼

這段代碼其實就是很簡單的封裝了一下,社區中好像有不少,好比next-cookie,nookie等,你們隨意使用,我這邊就用這個了,反正夠用就行,尚未任何依賴。

最後,我還把登陸受權邏輯大概組織了一下,畫了個流程圖:

以上就是準備工做,嗯,沒錯,這些只是準備工做,由於這只是一套登陸+受權的邏輯,而我想講的是受權驗證最佳實踐,而與後臺的接口驗證邏輯纔是這幾個系統最讓我頭疼的地方,接下來說的就是驗證部分。

驗證

很簡單,一個商業系統,除了登陸和註冊以外的全部的接口應該都是須要進行驗證用戶身份的,通常我這邊先後臺都是經過token來進行。不一樣的先後端約定也不同。

我的項目我通常用JWT,那麼token應該放在header的Authorization字段,而後token前面會加上Bearer ${token}

公司項目我這邊通常先後臺約定,後臺給定一個header字段,而後前端將token放入header字段。

驗證第一步,封裝fetch

具體的封裝方法,我前面的相關文章好像寫過,這裏就直接貼代碼了。

這裏的約定是先後端token的header字段是User-Token

import fetch from 'isomorphic-unfetch';
import qs from 'query-string';
import { getCookie } from './cookie';

// initial fetch
const nextFetch = Object.create(null);
// browser support methods
// ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'POST', 'PATCH', 'PUT']
const HTTP_METHOD = ['get', 'post', 'put', 'patch', 'delete'];
// can send data method
const CAN_SEND_METHOD = ['post', 'put', 'delete', 'patch'];

HTTP_METHOD.forEach(method => {
  // is can send data in opt.body
  const canSend = CAN_SEND_METHOD.includes(method);
  nextFetch[method] = (path, { data, query, timeout = 5000 } = {}) => {
    let url = path;
    const opts = {
      method,
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        Accept: 'application/json'
        /* 將token放入header字段 */
        'User-Token': getCookie('USER_TOKEN')
      },
      credentials: 'include',
      timeout,
      mode: 'cors',
      cache: 'no-cache'
    };

    // 構造query
    if (query) {
      url += `${url.includes('?') ? '&' : '?'}${qs.stringify(query)}`;
    }
  
    if (canSend && data) {
      opts.body = JSON.stringify(data);
    }

    console.log('Request Url:', url);

    return fetch(url, opts)
      .then(res => res.json());
  };
});

export default nextFetch;
複製代碼

嗯,上面就寫好了驗證的邏輯,咱們每一次fetch請求都會從cookie取出來token塞進header裏。那麼問題來了,每一次都能放進去嗎?

答案是否認的!前面提到過服務端渲染框架的一大特色就是服務端獲取數據,咱們不少狀況都是在服務端獲取的數據(若是你使用了getInitialProps,那麼系統初始化或者頁面刷新的時候數據都是服務端獲取的),服務端是沒法經過js-cookie獲取數據的,有人可能會說了,上面不是封裝了cookie能夠從服務端獲取嗎?嗯是封裝了,但是仔細看一下須要傳第二個參數ctx.req,難道每個fetch咱們都把req傳進去嗎?不切實際並且不符合開發常理~因此繼續深刻探索~

驗證第二步,正確而又優雅的獲取cookie

這裏我仔細想過其實有兩個方案,雖然其中一個方案較爲麻煩不過卻也有適合的場景,爲了表示個人研究歷程艱辛,這裏仍是都說一下:

  • 第一種:服務端單獨傳入req給fetch,客戶端直接從cookie獲取

這裏說的是服務端單獨傳給fetch,而不是每個請求都把req傳給fetch,也就是說服務端與客戶端的請求寫法會發生變化。

// nextFetch.js
HTTP_METHOD.forEach(method => {
  ...
  /* 新增一個req屬性,客戶端不傳就是undefined */
  nextFetch[method] = (path, { data, query, timeout = 5000, req } = {}) => {
    let url = path;
    const opts = {
      method,
      headers: {
        ...
        /* 將token放入header字段 */
        'User-Token': getCookie('USER_TOKEN', req) 
      },
     ...
    };
   ...
  };
});

export default nextFetch;
複製代碼

此時咱們的getInitialProps方法也許要發生變化。

/pages/home.js

==========> 以前的寫法

import Home from '../../containers/home';
import { fetchHomeData } from '../../redux/actions/home';

Home.getInitialProps = async (props) => {
  const { store, isServer } = props.ctx;
  store.dispatch(fetchHomeData());
  return { isServer };
};

export default Home;

==========> 以後的寫法

import Home from '../../containers/home';
import { fetchHomeDataSuccess } from '../../redux/actions/home';
import nextFetch from '../../core/nextFetch';

Home.getInitialProps = async (props) => {
  const { store, isServer, req } = props.ctx;
  // 不經過action,而是直接傳入req到fetch獲取數據,最後觸發success的action來更改state
  const homeData = await nextFetch.get(url, { query, req });
  store.dispatch(fetchHomeDataSuccess(homeData));
  return { isServer };
};

export default Home;

複製代碼

其實這種方案也沒什麼太大的問題,惟一的問題就是整個流程變的有些不三不四,原則上咱們的獲取數據都是經過派發action來獲取,在這種場景下就變成了直接請求獲取,成功再觸發成功的action。這種方式不推薦的緣由是讓請求的寫法區分紅兩種,服務端和客戶端獲取的方式不同,感受邏輯稍微混亂了一些,我的開發也沒啥大問題,不過若是合做開發的話每一個人事先須要商量好,不過也不是沒有適用的場景,當系統比較簡單沒有redux等狀態管理機制的時候,就能夠這麼用~

綜上分析,這種方案其實也不是一無可取,它很適合無狀態管理場景,不須要redux這種東西的時候就挺完美,那樣咱們就能夠把獲取到的數據直接做爲props傳給組件了

  • 第二種:代碼邏輯不變,token不在fetch內獲取,而是在redux層獲取傳入fetch

這個方案是我其中一個系統在用的方案,想法就是不想改變獲取數據的邏輯,也不但願req傳入到action或者fetch,思路就是客戶端經過js-cookie獲取token,服務端沒法經過js-cookie獲取那麼就從其餘地方獲取,服務端經過state獲取token(上面提到過我在登陸受權的時候二者都存了一次,首尾呼應一下),此時總體的驗證邏輯就是下面這樣。

// nextFetch.js

import fetch from 'isomorphic-unfetch';
...

HTTP_METHOD.forEach(method => {
  // is can send data in opt.body
  const canSend = CAN_SEND_METHOD.includes(method);
  /* 新增token屬性,接收上一層傳過來的token */
  nextFetch[method] = (path, { data, query, timeout = 5000, token } = {}) => {
    let url = path;
    const opts = {
      ...
      headers: {
        ...
        'User-Token': token
      },
      ...
    };
    ...
  };
});

export default nextFetch;
複製代碼

能夠看到,nextFetch裏增長了一個token參數,那麼這個token是從哪傳過來的呢?嗯,上面說了,是從redux層傳過來的,我異步邏輯用的是redux-saga,其餘的同理同樣。

import { take, put, fork, select } from 'redux-saga/effects';
import { FETCH_HOME_DATA } from '../../../constants/ActionTypes';
import {
  fetchHomeDataFail,
  fetchHomeDataSuccess,
} from '../../actions/home';
import api from '../../../constants/ApiUrlForBE';
import nextFetch from '../../../core/nextFetch';
import { getToken } from '../../../core/util';
/**
 * 獲取首頁數據
 */
export function* fetchHomeData() {
  while (true) {
    yield take(FETCH_HOME_DATA);
    /* 獲取token */
    const token = yield getToken(select);
    const query = {...queryProps}
    try {
      const data = yield nextFetch.get(api.home, { token, query });
      yield put(fetchHomeDataSuccess(data));
    } catch (error) {
      yield put(fetchHomeDataFail());
    }
  }
}

export default [
  fork(fetchHomeData)
];
複製代碼

咱們在saga使用nextFetch獲取數據的時候,提早把token獲取到傳入了nextFetch,用到了一個方法。

/**
 * 獲取token,若是是客戶端經過cookie獲取,若是是服務端經過state獲取
 * @param {Function} select 
 */
export function getToken (select) {
  return process.browser
    ? getCookie('USER_TOKEN')
    : select(state => state.user.auth.token);
}
複製代碼

存在的問題? 這個方案我以爲真的還能夠,封裝完成以後也不麻煩,看起來也很優雅,而且團隊開發也沒任何問題,應該每一個人的獲取流程已經被統一封裝的代碼限制好了。不過仍是存在小瑕疵的,就是每個saga都須要單獨獲取token流程而後塞進nextFetch。

驗證第三步,最佳解決方案

上面的方案是我在幾個系統的編寫中不斷設計不斷改進又不斷推翻的過程,而本質問題其實就是服務端和客戶端不能共用cookie的緣由,其實第二個方案已經還能夠了,至少寫起來不算醜,封裝好了以後也真的不麻煩,可是我在想,真的有必要每個saga都走一次獲取token的流程而後再傳入fetch裏嗎?若是能在fetch裏把這件事作了,那該多好。

有了上面的想法,突然靈光一現,我所我是夢裏想到的大家可能也不信~哈哈,可是確實是靈光一現。上面也提到過了,第二種方案說的是nextFetch的接收參數裏增長一個token屬性,咱們把token傳進來使用。那麼我就想了,若是咱們獲取到token的時候就賦值給nextFetch不就能夠了嗎?既然有想法了,就趕快試一試~

// 受權邏輯initialize
import { getCookie } from './cookie';
/* 引入nextFetch */
import nextFetch from './nextFetch';
import { authUserSuccess } from '../redux/actions/user';

/**
 * 進入系統初始化函數,用於用戶受權相關
 * @param {Object} ctx
 */
export default function(ctx) {
  const { req, store } = ctx;
  const userToken = getCookie('USER_TOKEN', req);
  /** 增長下面一行,將獲取到的token賦值到nextFetch的Authorization屬性上 **/
  nextFetch.Authorization = userToken;
  if (userToken && !store.getState().user.auth.user) {
    ...
    store.dispatch(
      authUserSuccess(payload)
    );
  }
}

複製代碼

此時,咱們在請求裏獲取token就變成了下面這樣。

import fetch from 'isomorphic-unfetch';
...

HTTP_METHOD.forEach(method => {
  // is can send data in opt.body
  const canSend = CAN_SEND_METHOD.includes(method);
  nextFetch[method] = function (path, { data, query, timeout = 5000 } = {}) {
    let url = path;
    const opts = {
      ...
      headers: {
        ...
        /* 獲取token,若是是瀏覽器環境拿cookie的,若是是node端,拿自身的Authorization屬性 */
        'User-Token': process.browser ? getCookie('USER_TOKEN') : this.Authorization
      },
      ...
    };
    ...
  };
});

export default nextFetch;
複製代碼

咱們嘗試在瀏覽器打印一下~

能夠看到,咱們驗證成功以後,成功將token賦值給了nextFetch.Authorization屬性,所以,這個方案是可行的。

那麼最終的解決方案就是,受權處將token做爲屬性賦值給nextFetch,而後nextFetch在客戶端從cookie拿,在服務端端從自身屬性拿,這樣咱們在其餘位置就無須再進行額外多餘的操做了~

其實最初初版方案,想的是用global變量,不過仔細一想發現問題,global是服務端共享的,不一樣用戶進行賦值會被覆蓋,高併發場景會出現問題,確定不能用的。

總結

代碼可能抽象的太厲害了,我這裏用腳手架新開了一個auth分支,你們能夠跑一遍代碼~而且裏面的fetch封裝的也很完整~按需使用~

代碼地址:next-antd-scaffold_auth

這篇文章或許講的有點亂,或許用了太多本身的邏輯習慣,不過中心思想是幫助你們簡化登陸受權驗證的邏輯,其中的幾種方案其實都是可行的,你們在本身的項目裏應該也有本身的方案,能夠一塊兒進行交流~

交流羣:

相關文章
相關標籤/搜索