這幾年 SPA(Single Page App)當道,造就一個現象就是 Server-side 及 Client-side 壁壘分明。如果你是 Client-side 的開發者,可能沒什麼機會自己架一個 web server,所以也不會實作到 session。自己本身也因為這樣,雖然開發 Web 應用已經有幾年的時間,汗顏的是,一直沒有好好了解 session 及 cookie。

前一個專案還是使用 SPA 架構,但上線後發現,SPA 要 tune SEO 相當的麻煩,這是從架構面就有的問題,因此最後是用相當骯髒的方式來處理。新的專案想要避免這種情況,決定自己架 web server 來提供靜態頁面。

啊有點離題了,希望未來有機會能談談前述的主題,但總之,這樣的架構(web server 提供靜態頁面),client 端對 api server 的驗證還是靠 access token,但如果是對 web server 本身的話就可以利用 session 來驗證使用者。

Node.js 上面有一個好用的驗證身分 library — passport.js。當然,照著官方教學你可以輕易的在你的 web app 上作單純的帳密登入甚至是第三方登入,乍看之下很神,但如果你了解 session 和 cookie 的機制的話,你會發現這不是無中生有,都是有跡可循的。

先從 cookie 開始介紹!

最常見到的 Cookie 應用是在表單填寫:假設現在頁面上的資料填到一半,不小心把網頁關掉,這時再重新打開發現先前填的內容還在的話,靠的就是 cookie。

實作原理很簡單,client 端的程式在一旦填寫的資料有變動時,就把該資訊寫入 cookie。Cookie 由瀏覽器處理,具有兩個特性:

  • Domain specific:只針對原本的 domain 起作用。舉例,在 *.example.com 存入的 cookie,不會出現在 *.not-example.com

  • 到了所設定的生命期限之後會失效。(如果是在 server-side 設定的話,預設是在關閉瀏覽器後失效,後面會詳述)

因為第二點,所以在新增 cookie 時,要指定 key 及 value,還有生命期限:

// 在 www.example.com 的頁面中
function setCookie(cname, cvalue, exdays) {
    var d = new Date();
    d.setTime(d.getTime() + (exdays*24*60*60*1000));
    var expires = "expires="+d.toUTCString();
    document.cookie = cname + "=" + cvalue + "; " + expires;
}

執行 setCookie('name', 'jcc', 1) 後,會在只有 www.example.com 作用的 cookie 中加入 name=jcc 字串,並於一天後刪除。

而下次瀏覽器造訪 www.example.com 時,就可以從 cookie 去取得裡面有存的資料,詳細作法請見 W3C 介紹

上面這個範例是單純應用在瀏覽器端。而 Cookie 的另一個特性是:在向該 domain 的 server 發送請求時,也會被一併帶進去該請求中。因此,記得不要讓 cookie 的內容太多,會增加傳輸量的負擔。另外透過這個特性,可以做到驗證客戶端的功能。

Cookie 的規範中定義了:伺服器端從 request 中接受到 cookie 的訊息,在產生 response 的時候,也會一起回覆給用戶端。

這個行為也告訴我們:在伺服器這邊也可以設定 cookie。由伺服器這邊設定給 cookie 的訊息,可能就會有關身分驗證,因此稍微中斷一下,接下來先來介紹 session。

Session

Session 負責紀錄在 web server 上的使用者訊息。Session 機制會在一個用戶完成身分認證後,存下所需的用戶資料,接著產生一組對應的 id,存入 cookie 後傳回用戶端。

這個 id 要是獨特的,所以會使用 uuid 的機制,重複的機率非常非常低。

因此當下次用戶端發送請求時,如果帶有該 id 資訊,web server 就會認為該請求是來自該名使用者,達到驗證用戶的目的。這個時候防偽的機制就相當重要,如果伺服器端的實作有問題,再加上用戶端竄改 cookie,就有可能被偽造身分。

假設這個漏洞百出的情況:現在伺服器端單純由 cookie 內設定的 id 名稱來判斷用戶,也就是說,看到 cookie 內 dotcom_user=jcc,就認為目前用戶是 jcc。如果這樣,只要把 cookie 改成 dotcom_user=messi,身分馬上就變成 messi 了。如果改成 dotcom_user=admin 呢?說不定就得到了管理者權限。。。

這個時候就要靠簽章來驗證資料的真實性。

Signed Cookie

所以當伺服器端在產生 cookie 時,都會加上 secret 來作 hash,來保證回來的資料沒有被更動過。

假設現在的 cookie 資料是:

{ dotcom_user: 'jcc' }

搭配上一段秘密字串 this_is_my_secret_and_fuck_you_all,來作 sha1:

var r = sha1('this_is_my_secret_and_fuck_you_all' + 'jcc')
// d01a3d595af33625be4159de07a20b79a1540e54

最後回傳到用戶端的 cookie 為

{ 
  dotcom_user: 'jcc',
  'dotcom_user.sig': 'd01a3d595af33625be4159de07a20b79a1540e54'
}

這時如果用戶端更改了 cookie,因為他不知道秘密字串是什麼,所以無法產生正確的 hash 值,因此在校對時就會出錯。這樣就可以避免掉被竄改 cookie 的可能了!

express-session

express app 上可使用 express-session 來輕鬆設定 session 的功能。在產生傳回用戶端包含 session id 的 cookie 時,就有用到上面提到的 signed cookie 概念,因此一定要設定 config 裡的 secret 屬性,另外還有一些額外的設定:

  • path: 那些路徑才會發送該 cookie,預設為 '/'

  • httpOnly:設定是不是只允許在 server-side 更改該 cookie(也就是不允許透過 javascript 設定),預設為 true

  • secure:支援 https 的情況下開啟可以提高 cookie 安全性,預設為 false

  • maxAge:cookie 在多久會過期,是與現在時間的相差,單位為毫秒。如果沒有設定或設為 0,該 cookie 就會是 browser-session cookie,會在關閉瀏覽器後移除。

express-session 有一個 store ,是用來設定 session 的儲存方式,預設為 MemoryStore。看一下這個警語:

Warning The default server-side session storage, MemoryStore, is purposely not designed for a production environment. It will leak memory under most conditions

表示預設值 MemoryStore 不應用在正式環境,在上線前記得改成其他選項。

Session Store

儲存 session 有下列四種方法,1)內存 2)cookie 3)緩存 4)資料庫。內存就是上面所說的 MemoryStore,有 memory leak 的風險,但適合用在開發環境;cookie 的方式就是把 session 存在 cookie 中,配合簽章防偽,但這個方法會讓 request 傳輸量變大,而且會有回放的風險,不推薦使用;資料庫的方式比較不適合用在 session,有特別的情況再考慮。

因此大部分正式環境,都會用緩存的方式,常見的有 redismemcache

最後,上面的說明大量了引用這篇文章,寫的相當好,我這篇其實只是換句話說而已,如果對這裡的解說有不了解可以去看看原文。