阅读 395

Next.js实践总结 - 登录授权验证最佳方案

最近做了几个项目都是使用脚手架next-antd-scaffold来做的,在系统的开发过程中,登录授权以及路由鉴权这一块,一直在琢磨与改进,希望能找到一个最优解,今天就把个人总结的几个Next.js授权验证方案来跟大家分享一下~

这里来说一下为啥是Next.js方案,因为SSR框架的不同,首屏渲染会在服务端进行,所以一些处理或者请求与普通的SPA就有一些区别,普通的客户端渲染,登录验证只需要一套逻辑就可以了,而在Next.js里面服务端和客户端其实需要单独处理,因此,抽象出一套统一便利的解决方案,有助于我们业务的开发~

因为Nuxt与Next基本大同小异,而授权验证又与代码无关,逻辑设计层面的事情,所以我觉得应该是一种SSR框架的通用登录授权方案了~

登录

登录逻辑很简单,也没什么可说的,无论是什么系统,登录模块应该都是必不可少的,那么我就来说一下我这边开发过程中遇到的一些问题和总结吧。一般来说,对于商业系统或者博客类系统,登录有两种场景。

第一种:用户第一次进入系统,那么提供给用户的就是登录页面,用户登录完进入系统;

第二种,用户登录过系统,系统保存用户的授权信息,在一定的时间内,不会再进行登录,用户进来直接进入系统首页或者url页面,当用户授权信息失效或用户清理了浏览器,会提示用户重新登录。

登录成功之后我们将用户信息写入指定位置(一般用户相关信息放入state,用户授权信息放入cookie),方便接下来进行授权验证操作。

关于登录的一个小问题

关于用户授权信息过失效重新登录,其实又分为两种情况:第一种,用户进入系统页面(这里不一定是首页,可能是任何页面)之后发送该页面的请求,发现用户授权过期,此时提示用户登录失效,重新登录;第二种,用户进入系统在浏览器显示页面之前(也就是服务端的时候),就判断出来用户登录失效了,此时重定向到登录页(无论用户打开的是什么页面)。

第一种场景我称之为闪现登录(顾名思义,用户进入系统之后会重定向到登录页,又一个一闪而过的页面切换过程),第二种我称为无闪现登录方案。并不是说无闪现就一定比闪现的好,两种方案在商业系统中应该都有被使用,具体看自己的业务场景~这里也不打算细聊,只是写到这了就简单说说~

授权

授权,就是前后端关于接口的权限定义,接口是否可以被所有人访问,如果不是,前后端校验的是何字段,放在什么位置等等。每个人每个team都有自己的代码习惯,这里不强求所有人都按照我的习惯去写,下面的方案只是自己的业务场景抽象,大家可以根据自己习惯适当改进使用~

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

这篇文章或许讲的有点乱,或许用了太多自己的逻辑习惯,不过中心思想是帮助大家简化登录授权验证的逻辑,其中的几种方案其实都是可行的,大家在自己的项目里应该也有自己的方案,可以一起进行交流~

交流群:

关注下面的标签,发现更多相似文章
评论