CORS原理及@koa/cors源碼解析

首發於 我的博客

目錄

  • 跨域
  • 簡單請求和複雜請求
  • 服務端如何設置CORS
  • @koa/cors是怎麼實現的

跨域

爲何會有跨域問題?

這是瀏覽器的同源策略所形成的,同源策略限制了從同一個源加載的文檔或腳本如何與來自另外一個源的資源進行交互。這是一個用於隔離潛在惡意文件的重要安全機制。前端

必定要注意跨域是瀏覽器的限制,其實你用抓包工具抓取接口數據,是能夠看到接口已經把數據返回回來了,只是瀏覽器的限制,你獲取不到數據。用postman請求接口可以請求到數據。這些再次印證了跨域是瀏覽器的限制。

如何解決跨域?

  • jsonp: 帶有src屬性的標籤均可以用來, 可是隻能處理GET請求
  • document.domain + iframe跨域
  • location.hash + iframe
  • window.name + iframe
  • postMessage跨域
  • Nginx配置反向代理
  • CORS(跨域資源共享):支持全部類型的HTTP請求

相信你們對於以上的解決方法都很熟悉,這裏再也不對每一種方法展開講解,接下來主要講一下CORS;nginx

簡單請求和非簡單請求

瀏覽器將CORS跨域請求分爲簡單請求和非簡單請求;git

若是你使用nginx反向代理解決的跨域問題,則不會有跨域請求這個說法了,由於nginx反向代理就使得先後端是同一個域了,就不存在跨域問題了。

只要同時知足一下兩個條件,就屬於簡單請求
(1)使用下列方法之一:github

  • head
  • get
  • post

(2)請求的Heder是json

  • Accept
  • Accept-Language
  • Content-Language
  • Content-Type: 只限於三個值:後端

    • application/x-www-form-urlencoded
    • multipart/form-data
    • text/plain

不一樣時知足上面的兩個條件,就屬於非簡單請求。
瀏覽器對這兩種的處理,是不同的。api

簡單請求

例子

對於簡單請求,瀏覽器直接發出CORS請求。具體來講,就是頭信息之中,增長一個Origin字段。
簡單請求
上面這個例子,post請求,Content-Typeapplication/x-www-form-urlencoded,知足簡單請求的條件;響應頭部返回Access-Control-Allow-Origin: http://127.0.0.1:3000;
瀏覽器發現此次跨域請求是簡單請求,就自動在頭信息之中,添加一個Origin字段;Origin字段用來講明請求來自哪一個源(協議+域名+端口號)。服務端根據這個值,決定是否贊成本次請求。跨域

CORS請求相關的字段,都以 Access-Control-開頭

  • Access-Control-Allow-Origin:必選瀏覽器

    • 請求頭Origin字段的值
    • *:接受任何域名
  • Access-Control-Allow-Credentials:可選,安全

    • true: 表示容許發送cookie,此時Access-Control-Allow-Origin不能設置爲*,必須指定明確的,與請求網頁一致的域名。
    • 不設置該字段:不須要瀏覽器發送cookie
  • Access-Control-Expose-Headers:可選

    • 響應報頭指示哪些報頭能夠公開爲經過列出他們的名字的響應的一部分。默認狀況下,只顯示6個簡單的響應標頭:

      • Cache-Control
      • Content-Language
      • Content-Type
      • Expires
      • Last-Modified
      • Pragma
    • 若是想要讓客戶端能夠訪問到其餘的首部信息,能夠將它們在 Access-Control-Expose-Headers 裏面列出來。

withCredentials 屬性

CORS請求默認不發送Cookie和HTTP認證信息,若是要把Cookie發到服務器,一方面須要服務器贊成,設置響應頭Access-Control-Allow-Credentials: true,另外一方面在客戶端發出請求的時候也要進行一些設置;

// XHR
var xhr = new XMLHttpRequest();
xhr.open('GET', 'http://example.com/', true); 
xhr.withCredentials = true; 
xhr.send(null);

// Fetch
fetch(url, {
  credentials: 'include'  
})

非簡單請求

非簡單請求就是那種對服務器有特殊要求的請求,好比請求方法爲PUTDELETE,或者Content-Type字段爲application/json;

1. 預檢請求和迴應

非簡單請求的CORS請求,會在正式通訊以前,增長一次HTTP查詢請求,稱爲「預檢」請求;
瀏覽器先詢問服務器,當前網頁所在的域名是否在服務器的許可名單之中,以及可使用哪些HTTP動詞和頭信息字段,只有獲得確定答覆,瀏覽器纔會發出正式的接口請求,不然就會報錯;
preflight

HTTP請求的方法是POST,請求頭Content-Type字段爲application/json。瀏覽器發現,這是一個非簡單請求,就自動發出一個預檢請求,要求服務器確承認以這樣請求。

1.1預檢請求

預檢請求用的請求方法是OPTIONS,表示這個請求是用來詢問的。頭信息裏面,關鍵字段是Origin,表示請求來自哪一個域。
除了Origin預檢請求的頭信息包括兩個特殊字段:

1.2預檢迴應

服務器收到預檢請求之後,檢查了OriginAccess-Control-Request-MethodAccess-Control-Request-Headers字段之後,確認容許跨域請求,就能夠作出迴應。
上面的HTTP迴應中,關鍵的是Access-Control-Allow-Origin字段,表示http://127.0.0.1:3000能夠請求數據。該字段也能夠設爲星號,表示贊成任意跨源請求。

若是瀏覽器否認了「預檢」請求,就會返回一個正常的HTTP迴應,可是沒有任何CORS相關的頭信息字段,這時,瀏覽器就會認定,服務器不一樣意預檢請求,所以觸發一個錯誤,被XMLHttpRequest對象的onerror回調函數捕獲h。

服務器迴應的其餘CORS字段

  • Access-Control-Allow-Methods:必需;它的值是逗號分隔的一個字符串,代表服務器支持的全部跨域請求的方法。注意,返回的是全部支持的方法,而不單是瀏覽器請求的方法。這是爲了不屢次預檢請求。
  • Access-Control-Allow-Headers:若是瀏覽器請求頭裏包括Access-Control-Request-Headers字段,則Access-Control-Allow-Headers字段是必需的。它也是一個逗號分隔的字符串,代表服務器支持的全部頭信息字段,不限於瀏覽器在預檢中請求的字段。
  • Access-Control-Allow-Credentials:與簡單請求時含義相同。
  • Access-Control-Allow-Max-Age: 可選,用來指定本次預檢請求的有效期。單位爲秒。在有效期內,不用發出另外一條預檢請求

2.正常請求和迴應

一旦服務器經過了預檢請求,之後每次瀏覽器正常的CORS請求,就都跟簡單請求同樣,會有一個Origin頭信息字段。服務器的迴應,也都會有一個Access-Control-Allow-Origin頭信息字段;

normal

服務端如何設置CORS

單獨接口單獨處理

好比一個簡單的登陸頁面,須要給接口接口傳入 username和password 兩個字段;前端的域名爲 localhost:8900,後端的域名爲 localhost:3200,構成跨域。

1. 若是設置請求頭'Content-Type': 'application/x-www-form-urlencoded',這種狀況則爲簡單請求;

會有跨域問題,直接設置 響應頭 Access-Control-Allow-Origin*, 或者具體的域名;注意若是設置響應頭Access-Control-Allow-Credentialstrue,表示要發送cookie,則此時Access-Control-Allow-Origin的值不能設置爲星號,必須指定明確的,與請求網頁一致的域名。

const login = ctx => {
    const req = ctx.request.body;
    const userName = req.userName;
    ctx.set('Access-Control-Allow-Origin', '*');
    ctx.response.body = {
        data: {},
        msg: '登錄成功'
    };
}

2. 若是設置請求頭'Content-Type': 'application/json',這種狀況則爲非簡單請求

處理OPTIONS請求,服務端能夠單獨寫一個路由,來處理login的OPTIONS的請求

app.use(route.options('/login', ctx => {
    ctx.set('Access-Control-Allow-Origin', '*');
    ctx.set('Access-Control-Allow-Headers', 'Content-Type');
    ctx.status = 204;
    
}));

你們都知道前端調用服務端的時候,會調用不少個接口,而且每一個接口處理跨域請求的邏輯是徹底同樣的,咱們能夠把這部分抽離出來,做爲一箇中間件;

寫一箇中間件進行處理

首先了解一下koa中間件的「洋蔥圈」模型

洋蔥圈

將洋蔥的一圈看作是一箇中間件,直線型就是從第一個中間件走到最後一個,可是洋蔥圈就很特殊了,最先use的中間件在洋蔥最外層,開始的時候會按照順序走到全部中間件,而後按照倒序再走一遍全部的中間件,至關於每一箇中間件都會進入兩次,這就給了咱們更多的操做空間。

const Koa = require("koa");
const app = new Koa();
app.use((ctx, next) => {
    console.log('a - 1');
    next();
    console.log('a - 2');
})
app.use((ctx, next) => {
    console.log('b - 1');
    next();
    console.log('b - 2');
})
app.use((ctx, next) => {
    console.log('c - 1');
    next();
    console.log('c - 2');
})

app.listen(3200, () => {
    console.log('啓動成功');
});

輸出

a - 1
b - 1
c - 1
c - 2
b - 2
a - 2

Koa官方文檔上把外層的中間件稱爲「上游」,內層的中間件爲「下游」。
通常的中間件都會執行兩次,調用next以前爲一次,調用next時把控制按順序傳遞給下游的中間件。當下遊再也不有中間件或者中間件沒有執行 next 函數時,就將依次恢復上游中間件的行爲,讓上游中間件執行 next以後的代碼;

處理跨域的中間件簡單示例

const Koa = require("koa");
const app = new Koa();
const route = require('koa-route');
var bodyParser = require('koa-bodyparser');

app.use(bodyParser()); // 處理post請求的參數

const login = ctx => {
    const req = ctx.request.body;
    const userName = req.userName;
    const expires = Date.now() + 3600000; // 設置超時時間爲一小時後
    
    var payload = { 
        iss: userName,
        exp: expires
    };
    const Token = jwt.encode(payload, secret);
    ctx.response.body = {
        data: Token,
        msg: '登錄成功'
    };
}

// 將公共邏輯方法放到中間件中處理
app.use((ctx, next)=> {
    const headers = ctx.request.headers;
    if(ctx.method === 'OPTIONS') {
        ctx.set('Access-Control-Allow-Origin', '*');
        ctx.set('Access-Control-Allow-Headers', 'Authorization');
        ctx.status = 204;
    } else {
        next();
    }
})
app.use(route.post('/login', login));

app.listen(3200, () => {
    console.log('啓動成功');
});

上述示例代碼地址

@koa/cors是怎麼實現的

'use strict';

const vary = require('vary');

/**
 * CORS middleware
 *
 * @param {Object} [options]
 *  - {String|Function(ctx)} origin `Access-Control-Allow-Origin`, default is request Origin header
 *  - {String|Array} allowMethods `Access-Control-Allow-Methods`, default is 'GET,HEAD,PUT,POST,DELETE,PATCH'
 *  - {String|Array} exposeHeaders `Access-Control-Expose-Headers`
 *  - {String|Array} allowHeaders `Access-Control-Allow-Headers`
 *  - {String|Number} maxAge `Access-Control-Max-Age` in seconds
 *  - {Boolean} credentials `Access-Control-Allow-Credentials`
 *  - {Boolean} keepHeadersOnError Add set headers to `err.header` if an error is thrown
 * @return {Function} cors middleware
 * @api public
 */
module.exports = function (options) {
    const defaults = {
        allowMethods: 'GET,HEAD,PUT,POST,DELETE,PATCH',
    };
    // 默認的配置項和使用時設置的options進行一個融合
    options = Object.assign({}, defaults, options);

    // 由於函數的一些參數,exposeHeaders,allowMethods,allowHeaders的形式既能夠是String,也能夠是Array類型,
    // 若是是Array類型,也轉換爲用逗號分隔的字符串。
    if (Array.isArray(options.exposeHeaders)) {
        options.exposeHeaders = options.exposeHeaders.join(',');
    }

    if (Array.isArray(options.allowMethods)) {
        options.allowMethods = options.allowMethods.join(',');
    }

    if (Array.isArray(options.allowHeaders)) {
        options.allowHeaders = options.allowHeaders.join(',');
    }

    if (options.maxAge) {
        options.maxAge = String(options.maxAge);
    }

    options.credentials = !!options.credentials;
    options.keepHeadersOnError = options.keepHeadersOnError === undefined || !!options.keepHeadersOnError;

    return async function cors(ctx, next) {
        // If the Origin header is not present terminate this set of steps.
        // The request is outside the scope of this specification.
        const requestOrigin = ctx.get('Origin');

        // Always set Vary header
        // https://github.com/rs/cors/issues/10
        ctx.vary('Origin');
        // 若是請求頭不存在 origin,則直接跳出該中間件,執行下一個中間件
        if (!requestOrigin) return await next();

        // 對origin參數的不一樣類型作一個處理
        let origin;
        if (typeof options.origin === 'function') {
            origin = options.origin(ctx);
            if (origin instanceof Promise) origin = await origin;
            if (!origin) return await next();
        } else {
            origin = options.origin || requestOrigin;
        }

        const headersSet = {};

        function set(key, value) {
            ctx.set(key, value);
            headersSet[key] = value;
        }
        /**
        * 非OPTIONS請求的處理
        * 
        */
       
        if (ctx.method !== 'OPTIONS') {
            // Simple Cross-Origin Request, Actual Request, and Redirects
            set('Access-Control-Allow-Origin', origin);

            if (options.credentials === true) {
                set('Access-Control-Allow-Credentials', 'true');
            }

            if (options.exposeHeaders) {
                set('Access-Control-Expose-Headers', options.exposeHeaders);
            }

            if (!options.keepHeadersOnError) {
                return await next();
            }
            try {
                return await next();
            } catch (err) {
                const errHeadersSet = err.headers || {};
                const varyWithOrigin = vary.append(errHeadersSet.vary || errHeadersSet.Vary || '', 'Origin');
                delete errHeadersSet.Vary;

                err.headers = Object.assign({}, errHeadersSet, headersSet, {
                    vary: varyWithOrigin
                });

                throw err;
            }
        } else {
            // Preflight Request

            // If there is no Access-Control-Request-Method header or if parsing failed,
            // do not set any additional headers and terminate this set of steps.
            // The request is outside the scope of this specification.
            if (!ctx.get('Access-Control-Request-Method')) {
                // this not preflight request, ignore it
                return await next();
            }

            ctx.set('Access-Control-Allow-Origin', origin);

            if (options.credentials === true) {
                ctx.set('Access-Control-Allow-Credentials', 'true');
            }

            if (options.maxAge) {
                ctx.set('Access-Control-Max-Age', options.maxAge);
            }

            if (options.allowMethods) {
                ctx.set('Access-Control-Allow-Methods', options.allowMethods);
            }

            let allowHeaders = options.allowHeaders;
            if (!allowHeaders) {
                allowHeaders = ctx.get('Access-Control-Request-Headers');
            }
            if (allowHeaders) {
                ctx.set('Access-Control-Allow-Headers', allowHeaders);
            }

            ctx.status = 204;
        }
    };
};

以上是 @koa/cors V3.0.0的源碼實現,若是你真正理解的CORS,看源碼的邏輯就會很是輕鬆。

主要是分兩個邏輯來處理,有預檢請求的和沒有預檢請求的。

對於非OPTIONS請求的處理,要根據狀況加上 Access-Control-Allow-OriginAccess-Control-Allow-CredentialsAccess-Control-Expose-Headers這三個響應頭部;

對於OPTIONS請求(預檢請求)的處理,要根據狀況加上 Access-Control-Allow-OriginAccess-Control-Allow-CredentialsAccess-Control-Max-AgeAccess-Control-Allow-MethodsAccess-Control-Allow-Headers這幾個響應頭部;

相關文章
相關標籤/搜索