不要再問我跨域的問題了,這篇文章全搞定!

web前端學習圈發表於2018-09-30

跨域這兩個字就像一塊狗皮膏藥一樣黏在每一個前端開發者身上,無論你在工作上或者面試中無可避免會遇到這個問題。為了應付面試,我每次都隨便背幾個方案,也不知道為什麼要這樣幹,反正面完就可以扔了,我想工作上也不會用到那麼多亂七八糟的方案。到了真正工作,開發環境有webpack-dev-server搞定,上線了服務端的大佬們也會配好,配了什麼我不管,反正不會跨域就是了。日子也就這麼混過去了,終於有一天,我覺得不能再繼續這樣混下去了,我一定要徹底搞懂這個東西!於是就有了這篇文章。

要掌握跨域,首先要知道為什麼會有跨域這個問題出現

確實,我們這種搬磚工人就是為了混口飯吃嘛,好好的調個介面告訴我跨域了,這種阻礙我們輕鬆搬磚的事情真噁心!為什麼會跨域?是誰在搞事情?為了找到這個問題的始作俑者,請點選:瀏覽器的同源策略。

這麼官方的東西真難懂,沒關係,至少你知道了,因為瀏覽器的同源策略導致了跨域,就是瀏覽器在搞事情。

所以,瀏覽器為什麼要搞事情?就是不想給好日子我們過?對於這樣的質問,瀏覽器甩鍋道:“同源策略限制了從同一個源載入的文件或指令碼如何與來自另一個源的資源進行互動。這是一個用於隔離潛在惡意檔案的重要安全機制。”

這麼官方的話術真難懂,沒關係,至少你知道了,似乎這是個安全機制。

所以,究竟為什麼需要這樣的安全機制?這樣的安全機制解決了什麼問題?別急,讓我們繼續研究下去。

沒有同源策略限制的兩大危險場景

據我瞭解,瀏覽器是從兩個方面去做這個同源策略的,一是針對介面的請求,二是針對Dom的查詢。試想一下沒有這樣的限制上述兩種動作有什麼危險。

沒有同源策略限制的介面請求

有一個小小的東西叫cookie大家應該知道,一般用來處理登入等場景,目的是讓服務端知道誰發出的這次請求。如果你請求了介面進行登入,服務端驗證通過後會在響應頭加入Set-Cookie欄位,然後下次再發請求的時候,瀏覽器會自動將cookie附加在HTTP請求的頭欄位Cookie中,服務端就能知道這個使用者已經登入過了。知道這個之後,我們來看場景:

  1. 你準備去清空你的購物車,於是開啟了買買買網站www.maimaimai.com,然後登入成功,一看,購物車東西這麼少,不行,還得買多點。

  2. 你在看有什麼東西買的過程中,你的好基友發給你一個連結www.nidongde.com,一臉yin笑地跟你說:“你懂的”,你毫不猶豫開啟了。

  3. 你饒有興致地瀏覽著www.nidongde.com,誰知這個網站暗地裡做了些不可描述的事情!由於沒有同源策略的限制,它向www.maimaimai.com發起了請求!聰明的你一定想到上面的話“服務端驗證通過後會在響應頭加入Set-Cookie欄位,然後下次再發請求的時候,瀏覽器會自動將cookie附加在HTTP請求的頭欄位Cookie中”,這樣一來,這個不法網站就相當於登入了你的賬號,可以為所欲為了!如果這不是一個買買買賬號,而是你的銀行賬號,那……

這就是傳說中的CSRF攻擊(淺談CSRF攻擊方式)。

看了這波CSRF攻擊我在想,即使有了同源策略限制,但cookie是明文的,還不是一樣能拿下來。於是我看了一些cookie相關的文章:聊一聊 cookie、Cookie/Session的機制與安全,知道了服務端可以設定httpOnly,使得前端無法操作cookie,如果沒有這樣的設定,像XSS攻擊就可以去獲取到cookie(Web安全測試之XSS);設定secure,則保證在https的加密通訊中傳輸以防截獲。

沒有同源策略限制的Dom查詢
  1. 有一天你剛睡醒,收到一封郵件,說是你的銀行賬號有風險,趕緊點進www.yinghang.com改密碼。你嚇尿了,趕緊點進去,還是熟悉的銀行登入介面,你果斷輸入你的賬號密碼,登入進去看看錢有沒有少了。

  2. 睡眼朦朧的你沒看清楚,平時訪問的銀行網站是www.yinhang.com,而現在訪問的是www.yinghang.com,這個釣魚網站做了什麼呢?

// HTML<iframe name="yinhang" src="www.yinhang.com"></iframe>// JS// 由於沒有同源策略的限制,釣魚網站可以直接拿到別的網站的Domconst iframe = window.frames['yinhang']const node = iframe.document.getElementById('你輸入賬號密碼的Input')console.log(`拿到了這個${node},我還拿不到你剛剛輸入的賬號密碼嗎`)複製程式碼
    複製程式碼

由此我們知道,同源策略確實能規避一些危險,不是說有了同源策略就安全,只是說同源策略是一種瀏覽器最基本的安全機制,畢竟能提高一點攻擊的成本。其實沒有刺不穿的盾,只是攻擊的成本和攻擊成功後獲得的利益成不成正比。

跨域正確的開啟方式

經過對同源策略的瞭解,我們應該要消除對瀏覽器的誤解,同源策略是瀏覽器做的一件好事,是用來防禦來自邪門歪道的攻擊,但總不能為了不讓壞人進門而把全部人都拒之門外吧。沒錯,我們這種正人君子只要開啟方式正確,就應該可以跨域。

下面將一個個演示正確開啟方式,但在此之前,有些準備工作要做。為了本地演示跨域,我們需要:

  1. 隨便跑起一份前端程式碼(以下前端是隨便跑起來的vue),地址是http://localhost:9099。

  2. 隨便跑起一份後端程式碼(以下後端是隨便跑起來的node koa2),地址是http://localhost:9971。

同源策略限制下介面請求的正確開啟方式

1.JSONP

在HTML標籤裡,一些標籤比如script、img這樣的獲取資源的標籤是沒有跨域限制的,利用這一點,我們可以這樣幹。

後端寫個小介面:

// 處理成功失敗返回格式的工具const {successBody} = require('../utli')class CrossDomain {  static async jsonp (ctx) {    // 前端傳過來的引數    const query = ctx.request.query    // 設定一個cookies    ctx.cookies.set('tokenId', '1')    // query.cb是前後端約定的方法名字,其實就是後端返回一個直接執行的方法給前端,由於前端是用script標籤發起的請求,所以返回了這個方法後相當於立馬執行,並且把要返回的資料放在方法的引數裡。    ctx.body = `${query.cb}(${JSON.stringify(successBody({msg: query.msg}, 'success'))})`  }}module.exports = CrossDomain複製程式碼

簡單版前端:

<!DOCTYPE html><html>  <head>    <meta charset="utf-8">  </head>  <body>    <script type='text/javascript'>      // 後端返回直接執行的方法,相當於執行這個方法,由於後端把返回的資料放在方法的引數裡,所以這裡能拿到res。      window.jsonpCb = function (res) {        console.log(res)      }    </script>    <script src='http://localhost:9871/api/jsonp?msg=helloJsonp&cb=jsonpCb' type='text/javascript'></script>  </body></html>複製程式碼

簡單封裝一下前端這個套路:

/** * JSONP請求工具 * @param url 請求的地址 * @param data 請求的引數 * @returns {Promise<any>} */const request = ({url, data}) => {  return new Promise((resolve, reject) => {    // 處理傳參成xx=yy&aa=bb的形式    const handleData = (data) => {      const keys = Object.keys(data)      const keysLen = keys.length      return keys.reduce((pre, cur, index) => {        const value = data[cur]        const flag = index !== keysLen - 1 ? '&' : ''        return `${pre}${cur}=${value}${flag}`      }, '')    }    // 動態建立script標籤    const script = document.createElement('script')    // 介面返回的資料獲取    window.jsonpCb = (res) => {      document.body.removeChild(script)      delete window.jsonpCb      resolve(res)    }    script.src = `${url}?${handleData(data)}&cb=jsonpCb`    document.body.appendChild(script)  })}// 使用方式request({  url: 'http://localhost:9871/api/jsonp',  data: {    // 傳參    msg: 'helloJsonp'  }}).then(res => {  console.log(res)})複製程式碼

2.空iframe加form

細心的朋友可能發現,JSONP只能發GET請求,因為本質上script載入資源就是GET,那麼如果要發POST請求怎麼辦呢?

後端寫個小介面:

// 處理成功失敗返回格式的工具const {successBody} = require('../utli')class CrossDomain {  static async iframePost (ctx) {    let postData = ctx.request.body    console.log(postData)    ctx.body = successBody({postData: postData}, 'success')  }}module.exports = CrossDomain複製程式碼

前端:

const requestPost = ({url, data}) => {  // 首先建立一個用來傳送資料的iframe.  const iframe = document.createElement('iframe')  iframe.name = 'iframePost'  iframe.style.display = 'none'  document.body.appendChild(iframe)  const form = document.createElement('form')  const node = document.createElement('input')  // 註冊iframe的load事件處理程式,如果你需要在響應返回時執行一些操作的話.  iframe.addEventListener('load', function () {    console.log('post success')  })  form.action = url  // 在指定的iframe中執行form  form.target = iframe.name  form.method = 'post'  for (let name in data) {    node.name = name    node.value = data[name].toString()    form.appendChild(node.cloneNode())  }  // 表單元素需要新增到主文件中.  form.style.display = 'none'  document.body.appendChild(form)  form.submit()  // 表單提交後,就可以刪除這個表單,不影響下次的資料傳送.  document.body.removeChild(form)}// 使用方式requestPost({  url: 'http://localhost:9871/api/iframePost',  data: {    msg: 'helloIframePost'  }})複製程式碼

3.CORS

CORS是一個W3C標準,全稱是"跨域資源共享"(Cross-origin resource sharing)跨域資源共享 CORS 詳解。看名字就知道這是處理跨域問題的標準做法。CORS有兩種請求,簡單請求和非簡單請求。

這裡引用上面連結阮一峰老師的文章說明一下簡單請求和非簡單請求。

瀏覽器將CORS請求分成兩類:簡單請求(simple request)和非簡單請求(not-so-simple request)。

只要同時滿足以下兩大條件,就屬於簡單請求。

(1)請求方法是以下三種方法之一:

  • HEAD

  • GET

  • POST

(2)HTTP的頭資訊不超出以下幾種欄位:

  • Accept

  • Accept-Language

  • Content-Language

  • Last-Event-ID

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

1、簡單請求

後端:

// 處理成功失敗返回格式的工具const {successBody} = require('../utli')class CrossDomain {  static async cors (ctx) {    const query = ctx.request.query    // *時cookie不會在http請求中帶上    ctx.set('Access-Control-Allow-Origin', '*')    ctx.cookies.set('tokenId', '2')    ctx.body = successBody({msg: query.msg}, 'success')  }}module.exports = CrossDomain複製程式碼

前端什麼也不用幹,就是正常發請求就可以,如果需要帶cookie的話,前後端都要設定一下,下面那個非簡單請求例子會看到。

fetch(`http://localhost:9871/api/cors?msg=helloCors`).then(res => {  console.log(res)})複製程式碼

2、非簡單請求非簡單請求會發出一次預檢測請求,返回碼是204,預檢測通過才會真正發出請求,這才返回200。這裡通過前端發請求的時候增加一個額外的headers來觸發非簡單請求。

不要再問我跨域的問題了,這篇文章全搞定!

後端:

// 處理成功失敗返回格式的工具const {successBody} = require('../utli')class CrossDomain {  static async cors (ctx) {    const query = ctx.request.query    // 如果需要http請求中帶上cookie,需要前後端都設定credentials,且後端設定指定的origin    ctx.set('Access-Control-Allow-Origin', 'http://localhost:9099')    ctx.set('Access-Control-Allow-Credentials', true)    // 非簡單請求的CORS請求,會在正式通訊之前,增加一次HTTP查詢請求,稱為"預檢"請求(preflight)    // 這種情況下除了設定origin,還需要設定Access-Control-Request-Method以及Access-Control-Request-Headers    ctx.set('Access-Control-Request-Method', 'PUT,POST,GET,DELETE,OPTIONS')    ctx.set('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, t')    ctx.cookies.set('tokenId', '2')    ctx.body = successBody({msg: query.msg}, 'success')  }}module.exports = CrossDomain複製程式碼

一個介面就要寫這麼多程式碼,如果想所有介面都統一處理,有什麼更優雅的方式呢?見下面的koa2-cors。

const path = require('path')const Koa = require('koa')const koaStatic = require('koa-static')const bodyParser = require('koa-bodyparser')const router = require('./router')const cors = require('koa2-cors')const app = new Koa()const port = 9871app.use(bodyParser())// 處理靜態資源 這裡是前端build好之後的目錄app.use(koaStatic(  path.resolve(__dirname, '../dist')))// 處理corsapp.use(cors({  origin: function (ctx) {    return 'http://localhost:9099'  },  credentials: true,  allowMethods: ['GET', 'POST', 'DELETE'],  allowHeaders: ['t', 'Content-Type']}))// 路由app.use(router.routes()).use(router.allowedMethods())// 監聽埠app.listen(9871)console.log(`[demo] start-quick is starting at port ${port}`)複製程式碼

前端:

fetch(`http://localhost:9871/api/cors?msg=helloCors`, {  // 需要帶上cookie  credentials: 'include',  // 這裡新增額外的headers來觸發非簡單請求  headers: {    't': 'extra headers'  }}).then(res => {  console.log(res)})複製程式碼

4.代理

想一下,如果我們請求的時候還是用前端的域名,然後有個東西幫我們把這個請求轉發到真正的後端域名上,不就避免跨域了嗎?這時候,Nginx出場了。

Nginx配置:

server{    # 監聽9099埠    listen 9099;    # 域名是localhost    server_name localhost;    #凡是localhost:9099/api這個樣子的,都轉發到真正的服務端地址http://localhost:9871     location ^~ /api {        proxy_pass http://localhost:9871;    }   }複製程式碼

前端就不用幹什麼事情了,除了寫介面,也沒後端什麼事情了。

// 請求的時候直接用回前端這邊的域名http://localhost:9099,這就不會跨域,然後Nginx監聽到凡是localhost:9099/api這個樣子的,都轉發到真正的服務端地址http://localhost:9871 fetch('http://localhost:9099/api/iframePost', {  method: 'POST',  headers: {    'Accept': 'application/json',    'Content-Type': 'application/json'  },  body: JSON.stringify({    msg: 'helloIframePost'  })})複製程式碼

Nginx轉發的方式似乎很方便!但這種使用也是看場景的,如果後端介面是一個公共的API,比如一些公共服務獲取天氣什麼的,前端呼叫的時候總不能讓運維去配置一下Nginx,如果相容性沒問題(IE 10或者以上),CROS才是更通用的做法吧。

同源策略限制下Dom查詢的正確開啟方式

1.postMessage

window.postMessage() 是HTML5的一個介面,專注實現不同視窗不同頁面的跨域通訊。

為了演示方便,我們將hosts改一下:127.0.0.1 crossDomain.com,現在訪問域名crossDomain.com就等於訪問127.0.0.1。

這裡是http://localhost:9099/#/crossDomain,發訊息方:

<template>  <div>    <button @click="postMessage">給http://crossDomain.com:9099發訊息</button>    <iframe name="crossDomainIframe" src="http://crossdomain.com:9099"></iframe>  </div></template><script>export default {  mounted () {    window.addEventListener('message', (e) => {      // 這裡一定要對來源做校驗      if (e.origin === 'http://crossdomain.com:9099') {        // 來自http://crossdomain.com:9099的結果回覆        console.log(e.data)      }    })  },  methods: {    // 向http://crossdomain.com:9099發訊息    postMessage () {      const iframe = window.frames['crossDomainIframe']      iframe.postMessage('我是[http://localhost:9099], 麻煩你查一下你那邊有沒有id為app的Dom', 'http://crossdomain.com:9099')    }  }}</script>複製程式碼

這裡是http://crossdomain.com:9099,接收訊息方:

<template>  <div>    我是http://crossdomain.com:9099  </div></template><script>export default {  mounted () {    window.addEventListener('message', (e) => {      // 這裡一定要對來源做校驗      if (e.origin === 'http://localhost:9099') {        // http://localhost:9099發來的資訊        console.log(e.data)        // e.source可以是回信的物件,其實就是http://localhost:9099視窗物件(window)的引用        // e.origin可以作為targetOrigin        e.source.postMessage(`我是[http://crossdomain.com:9099],我知道了兄弟,這就是你想知道的結果:${document.getElementById('app') ? '有id為app的Dom' : '沒有id為app的Dom'}`, e.origin);      }    })  }}</script>複製程式碼

結果可以看到:

不要再問我跨域的問題了,這篇文章全搞定!

2.document.domain

這種方式只適合主域名相同,但子域名不同的iframe跨域。

比如主域名是http://crossdomain.com:9099,子域名是http://child.crossdomain.com:9099,這種情況下給兩個頁面指定一下document.domain即document.domain = crossdomain.com就可以訪問各自的window物件了。

3.canvas操作圖片的跨域問題

這個應該是一個比較冷門的跨域問題,張大神已經寫過了我就不再班門弄斧了:解決canvas圖片getImageData,toDataURL跨域問題

最後

希望看完這篇文章之後,再有人問跨域的問題,你可以嘴角微微上揚,冷笑一聲,“不要再問我跨域的問題了”,揚長而去。


相關文章