Axios 与 Koa 跨域携带 cookie

跨域

起因

最近把 Koa-Server-Base 中关于传递 token 的方式全部改为直接由后端通过 ctx.cookies.set() 传递,之前是将 token 放在 body 中传递给前端。

ctx.cookies.set('token', token, { httpOnly: false, maxAge: config.security.JWT_APP_TOKEN_EXPIRE_TIME });
ctx.body = new Success({ type: user.type, id: user.id });

由于思维的惯性,我只是现在token会直接被设置在前端的cookie中,但是忽略了一个重要的问题——跨域

那这个问题也很快就暴露出来,前端 cookie 始终没有任何值,API 访问中 jwt 中间件检测不到 token,接口就无法被访问。

排查

排查的时候,在前台和后台打断点,都没有任何问题,也没有报错信息,看似不应该发生错误的逻辑并看不出任何问题但是就是无法将 token 带到前端 cookie 中。

但是服务器中线上的代码却没有问题,这就令我很是费解。没理由我本地有问题而线上正常啊,都是一套代码。

终于在对比本地请求和线上请求中,突然想到了跨域,我想我可能找到问题了。

在本地我的前端项目和服务器是两个端口,也就是跨域了,而服务器使用 Nginx 进行了转发,所以不存在跨域问题。

为什么现在才想起来这个问题?可能先使用 Postman 测试接口,看到 token 被正确的带到了 cookie 上我就放心了,却忽略了跨域的根本原因,就是跨域浏览器出于安全考虑而做的限制。所以 Postman 压根不会出现跨域问题。

找到问题之后就很好解决了,在本地环境的前后台代码中(解决跨域需要前后台配合),加入跨域解决方案,正式环境因为有 Nginx 做了端口转发,所以正式环境需要恢复这部分代码。

解决

问题的核心就是:

MDN解释:
credentials 是Request接口的只读属性,用于表示用户代理是否应该在跨域请求的情况下从其他域发送cookies。这与XHR的withCredentials 标志相似,不同的是有三个可选值(后者是两个):

  • omit: 从不发送cookies.
  • same-origin: 只有当URL与响应脚本同源才发送 cookies、 HTTP Basic authentication 等验证信息.(浏览器默认值,在旧版本浏览器,例如safari 11依旧是omit,safari 12已更改)
  • include: 不论是不是跨域的请求,总是发送请求资源域在本地的 cookies、 HTTP Basic authentication 等验证信息.

处理跨域,要在发送 http 请求时,需要设置请求头,注意以下两点:

  • 设置请求头 Access-Control-Allow-Credentials:true 属性(前台 && 后台)
  • 设置 Access-Control-Allow-Origin 为指定地址(后台)

在这遇到的是跨域携带 cookie 的问题,所以最终需要解决的是如何设置前台Axios与后台Koa解决跨域携带cookie

前端可以设置:

// Axios
axios.create({
      // `baseURL` 将自动加在 `url` 前面,除非 `url` 是一个绝对 URL。
      // 它可以通过设置一个 `baseURL` 便于为 axios 实例的方法传递相对 URL
      baseURL: proxy,
      // `timeout` 指定请求超时的毫秒数(0 表示无超时时间)
      // 如果请求话费了超过 `timeout` 的时间,请求将被中断
      timeout: 5000,
      // `withCredentials` 表示跨域请求时是否需要使用凭证, 是否携带cookies发起请求
      withCredentials: true, // 跨域携带cookie
      // `validateStatus` 定义对于给定的HTTP 响应状态码是 resolve 或 reject  promise 。如果 `validateStatus`
      // 返回 `true` (或者设置为 `null` 或 `undefined`),promise 将被 resolve; 否则,promise 将被 reject
      validateStatus: status => {
        return status >= 200 && status < 300;
      }
    });

后台设置(会报错):

app.use(cors({
  origin: '*',
  credentials: true, // 跨域携带 cookie
  allowMethods: ['GET', 'POST'],
  maxAge: 86400
}));

但是后台如果不指定明确的 oringin,会收到以下错误:

Access to XMLHttpRequest at 'http://localhost:7001/api/user/login' from origin
'http://localhost:3000' has been blocked by CORS policy: Response to preflight request
doesn't pass access control check: The value of the 'Access-Control-Allow-Origin' 
header in the response must not be the wildcard '*' when the request's credentials mode
is 'include'. The credentials mode of requests initiated by the XMLHttpRequest is
controlled by the withCredentials attribute.

后台设置(通过):

app.use(cors({
  origin: 'http://localhost:3000', // 指定origin为前端地址
  credentials: true, // 跨域携带 cookie
  allowMethods: ['GET', 'POST'],
  maxAge: 86400
}));

这样设置访问就不会有问题,而且cookie也如期而至。

对比一下两次的请求信息:

// before
Access-Control-Allow-Origin: *
Connection: keep-alive
Content-Length: 37
Content-Type: application/json; charset=utf-8
Date: Wed, 26 Dec 2018 10:54:29 GMT
Set-Cookie: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE1NDU4MjE2NjksImV4cCI6MTU0ODQxMzY2OSwiaWQiOjQsInR5cGUiOjB9.ceLYYvzjnPI76byu9WRhnTT_2YGDg8zcvMTEWK3_MEE; path=/; expires=Wed, 26 Dec 2018 11:37:41 GMT
Vary: Origin
X-Response-Time: 44ms

// after
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: http://localhost:3000
Connection: keep-alive
Content-Length: 37
Content-Type: application/json; charset=utf-8
Date: Wed, 26 Dec 2018 14:09:31 GMT
Set-Cookie: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE1NDU4MzMzNzEsImV4cCI6MTU0ODQyNTM3MSwiaWQiOjQsInR5cGUiOjB9.2aBo0fsnmOjS6JI8w6yTsVdf6o8GhVZLA5EWyoXN_fM; path=/; expires=Wed, 26 Dec 2018 14:52:43 GMT
Vary: Origin
X-Response-Time: 36ms

可以发现就是前面提到的请求头属性发生了变化。

做个笔记

前端在发送请求的时,是不会带上 cookie 的,需要通过设置 withCredentials: true 来解决,前端目前主流的几种发送 http 的方式都有其自己的跨域设置:

// Ajax
$.ajax({
    url: 'http://localhost:7001/api',
    type: 'GET',
    xhrFields: {
        // 解决跨域
        withCredentials: true
    },
    success: function (data) {
        console.log(data)
    }
})

// Axios
axios.create({
      // `baseURL` 将自动加在 `url` 前面,除非 `url` 是一个绝对 URL。
      // 它可以通过设置一个 `baseURL` 便于为 axios 实例的方法传递相对 URL
      baseURL: proxy,
      // `timeout` 指定请求超时的毫秒数(0 表示无超时时间)
      // 如果请求话费了超过 `timeout` 的时间,请求将被中断
      timeout: 5000,
      // `withCredentials` 表示跨域请求时是否需要使用凭证, 是否携带cookies发起请求
      withCredentials: true, // TODO: 正式环境使用 Nginx 转发,不存在跨域问题,正式环境设置该值为 false
      // `validateStatus` 定义对于给定的HTTP 响应状态码是 resolve 或 reject  promise 。如果 `validateStatus`
      // 返回 `true` (或者设置为 `null` 或 `undefined`),promise 将被 resolve; 否则,promise 将被 reject
      validateStatus: status => {
        return status >= 200 && status < 300;
      }
    });
    
// Fetch
fetch('http://localhost:7001/api',{
        method: 'GET',
        mode: 'cors', // 跨域
    }).then(res => {
        return res.json();
    }).then(json => {
        console.log('获取的结果', json.data);
        return json;
    }).catch(err => {
        console.log('请求错误', err);
    })

在这做个笔记,所以以上代码仅供参考,因为内部实现方式都不同,这里主要针对 Axios 来分析。

贴出完整Axios代码:

import axios from 'axios';
import _ from 'lodash';
import C from '../constant/const';
import { deleteCookie } from '../utils/cookie';
import { proxy } from '../config/config';
import Notify from '../utils/Notify';

export class BaseService {
  constructor() {
    this.$http = axios.create({
      // `baseURL` 将自动加在 `url` 前面,除非 `url` 是一个绝对 URL。
      // 它可以通过设置一个 `baseURL` 便于为 axios 实例的方法传递相对 URL
      baseURL: proxy,
      // `timeout` 指定请求超时的毫秒数(0 表示无超时时间)
      // 如果请求话费了超过 `timeout` 的时间,请求将被中断
      timeout: 5000,
      // `withCredentials` 表示跨域请求时是否需要使用凭证, 是否携带cookies发起请求
      withCredentials: true, // TODO: 正式环境使用 Nginx 转发,不存在跨域问题,正式环境设置该值为 false
      // `validateStatus` 定义对于给定的HTTP 响应状态码是 resolve 或 reject  promise 。如果 `validateStatus`
      // 返回 `true` (或者设置为 `null` 或 `undefined`),promise 将被 resolve; 否则,promise 将被 reject
      validateStatus: status => {
        return status >= 200 && status < 300;
      }
    });
  }

  axios(method, url, data) {
    return new Promise((resolve, reject) => {
      // TODO: production 环境注释这里的 console
      console.log('🚀 http request: ', method, url, data);
      this.$http[method](url, data)
        .then(function(resp) {
          const data = resp.data;
          const code = _.get(data, 'code');
          /**
           * @Description: 不使用 http 标准状态码,使用自定义状态码,自行处理返回状态
           * @author Martin
           * @date 2018-12-14
           */
          if (code && code !== 200) {
            if (_.get(data, 'errorCode') === C.ERROR_CODE.TOKEN_ERROR) {
              deleteCookie('token');
            }
            console.warn('💣 request warning: ', data);
            reject(data);
          }
          resolve(resp.data);
        })
        .catch(function(error) {
          Notify.info(C.ERROR_CODE.DESC[C.ERROR_CODE.SYSTEM_ERROR]);
          console.warn('🐞 request error: ', error);
          reject(error);
        });
    });
  }

  get(url) {
    return this.axios('get', url);
  }

  post(url, data) {
    return this.axios('post', url, data);
  }

  put(url, data) {
    return this.axios('put', url, data);
  }

  delete(url) {
    return this.axios('delete', url);
  }
}

结语

跨域问题是经常会出现的问题,解决跨域需要前后端配合,解决的方式也是有很多种。而针对不同的请求也有不同的解决方案。

而针对请求类型与具体需要,具体需要根据实际场景分析解决,理解简单请求与预检请求的差别,针对团队的开发习惯统一处理请求规则。

Nginx也是处理这类问题的一种解决方案,我个人就喜欢使用Nginx做端口转发来解决这类问题,同时配合一些代码管理工具方便维护项目。

参考

跨域资源共享 CORS 详解

HTTP访问控制(CORS)

Axios Using application/x-www-form-urlencoded format

跨域资源共享——CORS

Show Comments