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

这篇文章或许讲的有点乱,或许用了太多本身的逻辑习惯,不过中心思想是帮助你们简化登陆受权验证的逻辑,其中的几种方案其实都是可行的,你们在本身的项目里应该也有本身的方案,能够一块儿进行交流~

交流群:

相关文章
相关标签/搜索