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

木子星兮發表於2019-06-24

首發於個人部落格

目錄

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

跨域

為什麼會有跨域問題?

這是瀏覽器的同源策略所造成的,同源策略限制了從同一個源載入的文件或指令碼如何與來自另一個源的資源進行互動。這是一個用於隔離潛在惡意檔案的重要安全機制。

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

如何解決跨域?

  • jsonp: 帶有src屬性的標籤都可以用來, 但是隻能處理GET請求
  • document.domain + iframe跨域
  • location.hash + iframe
  • window.name + iframe
  • postMessage跨域
  • Nginx配置反向代理
  • CORS(跨域資源共享):支援所有型別的HTTP請求 相信大家對於以上的解決方法都很熟悉,這裡不再對每一種方法展開講解,接下來主要講一下CORS;

簡單請求和非簡單請求

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

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

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

  • head
  • get
  • post

(2)請求的Heder是

  • Accept
  • Accept-Language
  • Content-Language
  • Content-Type: 只限於三個值:
    • application/x-www-form-urlencoded
    • multipart/form-data
    • text/plain

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

簡單請求

例子

對於簡單請求,瀏覽器直接發出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這幾個響應頭部;

相關文章