Open aszx87410 opened 5 years ago
先感謝大大分享。
文中有小地方筆誤,如下 express-session 的客製化程度很高,可以自己傳進去產生 sessionID 的函式。若是沒有傳,預設會使用 uid(24),這邊的 uid 指的是 uid-safe 這個 library,會產生一個長度為 24 bytes 的隨機 ID。 這邊長度應該是 32 bytes 的隨機 ID。
@MMiooiMM 感謝回報!
但我仔細看了一下,應該是有些誤會
uid(24) 會產生 24 bytes 的隨機 ID 沒錯,32 是最後字串的長度,而不是原始的 bytes length
就如同你節錄的這段的下面所說的:
所以填入 24,最後產生出來的會是長度為 32 個字元的字串。
所以 uid(24) 產生的 buffer 長度為 24 bytes,經過 base64 編碼過後變成長度為 32 個字的字串 原文中寫的「會產生一個長度為 24 bytes 的隨機 ID」改為「會產生一個長度為 24 bytes 的 random buffer」可能會好一點
然後我其實寫這段的時候也沒有仔細想為什麼是這樣,剛剛惡補了一下 是因為最後出來的字串是把 buffer 經過 base64 編碼 而 2^6 = 64,所以 base64 就是每 6 個 bit 編成一個字 (24 * 8) / 6 = 32,因此最後出來就是長度為 32 的字串
我一開始覺得怪怪的地方是,後文(你補充的那段)與前文 24 byte 轉成 24 byte 覺得衝突,想說方法應該是要轉出 32
這個數字,但仔細看原始碼後,可能是我誤以為你補充的那段是描述 uid()
這個方法。
是我搞錯了,謝謝大大。
感谢好文章。 总结里有这么一段 : "第一種是 express-session,把 session information 存在記憶體裡面;第二種是 PHP,存在檔案裡面;最後一種則是 Rails,採用了之前提過的 cookie-based session,將資訊直接加密並且存在 cookie 裡。" 如果我的理解正确的话, 这里是否可以明确说明: 第一种express-session里,是把session id存在cookie,session information存在记忆体里。最后一种Rails,将session information 直接加密存在cookie里。 还有个小问题, Rails里, 既然将(整个)session存在cookie里,那么为什么要区分session id和session information呢? 谢谢!
如果我的理解正确的话, 这里是否可以明确说明: 第一种express-session里,是把session id存在cookie,session information存在记忆体里。最后一种Rails,将session information 直接加密存在cookie里。
是的,沒錯
还有个小问题, Rails里, 既然将(整个)session存在cookie里,那么为什么要区分session id和session information呢?
這邊的話需要採取一個比較宏觀的角度來看,因為若是從單一實作上的角度來看容易產生盲點。
Session 機制本身就是個狀態存取的機制,通常就是利用 session id + session information 這樣的組合來達成,而若是採用 cookie-based session,的確是無需 session id 就能夠取得資料,那為什麼還要有呢?
我認為理由是:「因為其他實作還是需要 session id」,而這些框架通常設計上都會考量到這種方便抽換的特性,如同 rails 文件裡面提到的:
The session is only available in the controller and the view and can use one of a number of different storage mechanisms:
當你使用不同種儲存結構時,id 就會是必要的。所以在上層的設計中 id 也是無可避免的,就算 cookie-based session 真的用不到,但依然還是可以加上一個 id 保持一致性。
或是換個角度想,API 一定有提供一個 method 是去取得 session id,假如我某一行代碼是去抓取這個 id,那應該無論底層是哪一種實作,都必須回傳一個 id 才對。無論底層是存在 cookie、檔案還是資料庫,都不應該改變這個行為。
有道理。我忽然想到,JWT的jti claim也是这个道理。 https://tools.ietf.org/html/rfc7519#section-4.1
4.1.7. "jti" (JWT ID) Claim
这种ID,不管是token(如以上的JWT)的,还是session的,都有用。比方说,在server端,用key-value store里维护blacklist。 谢谢!
去年看到這個的系列文 就想追一下 Laravel Session 和 Cookie 的實作 發現 Laravel 並沒有使用 PHP 原生的 session 機制 於是就有了這篇文 https://blog.goodjack.tw/2020/05/laravel-session-cookie.html
@goodjack 感謝分享,寫得很棒的一個系列!研究框架的 session 機制雖然花時間,但想必研究完以後也是功力大增XD
前言
這是一系列共三篇的文章,我稱之為 Session 與 Cookie 三部曲。系列文的目標是想要由淺入深來談談這個經典議題,從理解概念一直到理解實作方式。這是系列文的最後一篇,三篇的完整連結如下:
第一篇以白話的方式來談 Session 與 Cookie,全篇沒有談到太多技術名詞;第二篇直接去看 Cookie 的三份 RFC 來理解到底什麼是 Session,也補齊了一些 Cookie 相關的知識。而這一篇則是要深入 Session,一起帶大家看看三種不同的 Session 實作方式。
這三樣分別是 Node.js 的 Web 框架 Express、PHP 以及 Ruby on Rails。會挑選這三個是因為他們對於 Session 機制的實作都不同,是我覺得很適合拿來參考的對象。
好,接著就開始吧!
Express
Express 本身是個極度輕量的框架,有許多其他框架底下的基本功能,在這邊都要額外安裝 middleware 才能使用。
先來簡單介紹一下 middleware 的概念。在 Express 裡面,當收到一個 Request 之後就會轉交給相對應的 middleware 來做處理,處理完以後變成 Response 回傳回去。所以 Express 的本質其實就是一大堆 middleware。
用圖解釋的話會長這樣:
舉個例子好了,一段基本的程式碼會長這樣:
第一個 middleware 是 global 的,所以任何 request 都會先到達這個 middleware,而這邊可以對 req 或是 res 這兩個參數設置一些東西,最後呼叫
next
把控制權轉給下一個 middleware。而下一個 middleware 就可以拿到前面的 middleware 處理過後的資訊,並且輸出內容。如果沒有呼叫 next,代表不想把控制權轉移給下個 middleware。
在 Express 裡面,管理 Session 的 middleware 是 express-session,範例程式碼長這樣(改寫自官網範例):
使用了 session middleware 以後,可以直接用
req.session.key
來存取你要的資訊,同一個變數可以寫入也可以讀取,跟 PHP 的 $_SESSION 有異曲同工之妙。接著我們來看看 express-session 的程式碼吧!主要的程式碼都在 index.js 這個檔案,大概有快七百行,不太可能一行一行講解。
而且寫得好的 library,會花很多精力在向後相容以及資料合法性的檢查,這些都是一些比較瑣碎而且對於想要理解機制比較沒幫助的東西。
所以我會直接把程式碼稍微整理一下,去除掉比較不重要的部分並且重新組織程式碼,只挑出相關的段落。
我們會關注三個重點:
可以先來看產生 sessionID 的地方:
express-session 的客製化程度很高,可以自己傳進去產生 sessionID 的函式。若是沒有傳,預設會使用
uid(24)
,這邊的 uid 指的是 uid-safe 這個 library,會產生一個長度為 24 bytes 的隨機 ID。文件上有特別說明這個長度:
所以填入 24,最後產生出來的會是長度為 32 個字元的字串。
那這個 sessionID 是以什麼樣的形式存進 Cookie 的呢?
存在 cookie 裡面的 sessionID 的 key 一樣可以自己指定,但預設會是
connect.sid
,所以以後一看到這個 key 就知道這是 express-session 預設的 sessionID 名稱。內容的部分比較特別一點,會以
s:
開頭,後面接上signature.sign(sessionID, secret)
的結果。這邊要再看到 cookie-signature 這個 library,底下是一個簡單範例:
這邊的 sign 到底做了什麼呢?原始碼很簡單,可以稍微看一下:
就只是把你要 sign 的內容用 hmac-sha256 產生一個鑑別碼,並且加在字串後面而已,中間會用
.
來分割資料。若是你不知道什麼是 hmac 的話我稍微提一下,簡單來說就是可以對一串訊息產生鑑別碼,目的是為了保持資料的完整性讓它不被竄改。你可以想成它就是訊息對應到的一組獨一無二的代碼,如果訊息被改掉了,代碼也會不一樣。
以上面的範例來說,
hello
利用tobiiscool
這個 secret,得到的結果為:DGDUkGlIkCzPz+C0B064FNgHdEjox7ch8tOBGslZ5QI
,於是完整字串就變為:hello.DGDUkGlIkCzPz+C0B064FNgHdEjox7ch8tOBGslZ5QI
,前面是我的資料,後面是資料的鑑別碼。如果有人想竄改資料,例如說把前面改成 hello2,那這個資料的鑑別碼就不會是後面那一串,我就知道有人篡改資料了。所以藉由這樣的方式來保持資料完整性,其實原理跟 JWT 是差不多的,你看得到資料但沒辦法改它,因為改了會被發現。
你可能會疑惑說:那我幹嘛不把整個 sessionID 加密就好?為什麼要多此一舉用這種方式?我自己猜測是因為原始資料其實不怕別人看,只是怕人改而已;若是原始資料是敏感資訊,會用加密的方式。但因為原始資料只是 sessionID 而已,被別人看到也沒什麼關係,只要保障資料完整性即可。而且加密需要的系統資源應該比這種訊息驗證還多,因此才採用這種方式。
好,我們再講回來前面,所以 express-session 會把 sessionID 存在 cookie 裡面,key 是
connect.sid
,value 則是s:{sessionID}.{hmac-sha256(sessionID, secret)}
。好奇的話你可以去任何使用 Express 的網站然後看一下 cookie 內容,就可以找到實際的資料(或是自己隨便執行一個也行),這邊我用我的當作範例,我的 connect.sid 是: s%3AfZZVCDHefchle2LDK4PzghaR3Ao9NruG.J%2BsOPkTubkeMJ4EMBcnunPXW0Y7TWTucRSKIPNVgnRM,把特殊字元 decode 之後變成:
s:fZZVCDHefchle2LDK4PzghaR3Ao9NruG.J+sOPkTubkeMJ4EMBcnunPXW0Y7TWTucRSKIPNVgnRM
。也就是說我的 sessionID 是
fZZVCDHefchle2LDK4PzghaR3Ao9NruG
,鑑別碼是J+sOPkTubkeMJ4EMBcnunPXW0Y7TWTucRSKIPNVgnRM
。知道儲存 sessionID 的方式以後,從 cookie 裡面取得 sessionID 的方式應該也能看懂,就是把事情反過來做而已:
接下來就剩最後一個了,session 資訊到底存在哪裡?是存在記憶體、檔案,還是其他地方?
其實這個在程式碼裡面寫得很清楚了,預設是存在記憶體裡面的:
那到底是怎麼存呢?可以參考 session/memory.js:
首先用
Object.create(null)
創造出一個乾淨的 Object(這是很常用的一個方法,沒看過的可以參考:詳解 Object.create(null)),然後以 sessionID 作為 key,JSON.stringigy(session)
作為 value,存到這個 object 裡面。所以說穿了其實 express-session 的 session information 預設就是存在一個變數裡面而已啦,因此你只要一把 process 結束掉重開,session 的資料就都全部不見了。而且會有 memory leak 的問題,所以官方也不推薦用在 production 上面。
如果要用在 production 上面,必須額外再找
store
來用,例如說 connect-redis 就可以跟 express-session 搭配,把 session information 存在 redis 裡。以上就是 Express 常用的 middleware:express-session 的原始碼分析。從上面的段落我們清楚知道了 sessionID 的產生方式以及如何存在 cookie,還有 session information 所儲存的地方。
PHP(7.2 版本)
PHP 內建就有 session 機制,不必使用任何的 framework,而使用的方法也很簡單:
其實跟 express-session 的用法有點像,只是一個是
req.session
,一個是$_SESSION
。我原本也想跟剛剛看 express-session 一樣,直接去看 PHP 的原始碼,然後從中發現如何實作。但因為 PHP 的原始碼全部都是 C,對我這種幾乎沒寫過 C 的人來說很難看懂,因此我也只能反過來。先跟大家介紹 PHP 的 Session 機制是如何實作的,再從原始碼裡面去找證據支援。
首先呢,PHP 的 Session 機制與 express-session 差不多,都會在 Cookie 裡存放一個 sessionID,並且把 session information 存在伺服器。express-session 預設是存在記憶體,PHP 預設則是存在檔案裡面。
以上這些都可以在 PHP 的設定檔調整,都寫在
php.ini
裡面,底下以我的為例,列出一些相關的設定:在 Cookie 裡面你就能看見一個
PHPSESSID
,值大概長得像這樣:fc46356f83dcf5712205d78c51b47c4d
,這就是 PHP 所使用的 sessionID。接著你去
session.save_path
看,就會看到儲存你 session 資訊的檔案,檔名很好認,就是sess_
加上 sessionID:若是打開 session 檔案,內容會是被序列化(serialize)之後的結果:
這就是 PHP session 的真面目了。把 session information 全都存在檔案裡面。
若是想要研究 PHP session 的相關原始碼,最重要的檔案就是這兩個:ext/session/session.c 跟 ext/session/mod_files.c,前者管理 session 生命週期,後者負責把 session 實際存到檔案裡面或者是讀出來。後者其實就很像我們在 express-session 裡面看到的 Store,只要遵守一樣的 interface,就可以自己寫一個其他的 mod 出來,例如說 mod_redis.c 之類的。
接著我們一樣先來找找看 sessionID 是如何產生的,可以直接在 mod_files.c 搜尋相關字眼,就會找到底下這段:
這邊呼叫了
php_session_create_id
來產生 sessionID,然後會檢查有沒有產生重複的 id,有的話就重試最多三次。而php_session_create_id
則是存在於 session.c 那個檔案:重點其實只有這一個:
php_random_bytes_throw
,這個 function 如果繼續追下去會找到 ext/standard/php_random.h,然後找到 ext/standard/random.c,才是真正產生隨機數的地方。但最後找到的那個 function 想要看懂必須花一大段時間,因此我就沒有細看了。總之在不同作業系統上會有不同的產生方式,其中一種還會使用到 /dev/urandom。
知道了 sessionID 的產生方式以後,我們來看看 PHP 的 session information 是怎麼做 serialize 的。可以在官方文件上看到一個 function 叫做:
session_encode
,輸出的結果跟我們在 session 檔案裡面看到的資料一模一樣,而這個 function 的敘述寫著:接著我們直接在 session.c 裡面搜尋
session_encode
,會找到這一段:只是一個
php_session_encode
的 wrapper 而已,而且php_session_encode
也只是再呼叫別的東西:return PS(serializer)->encode();
這一句才是重點。其實追到這邊的時候就有點卡住,因為不清楚這邊的serializer
是從哪邊來的。但往下稍微看一下程式碼,找到一段應該是相關的:會知道相關是因為
#define PS_DELIMITER '|'
這一行,這個符號在 session 檔案裡有出現,可以猜測應該是拿來分隔什麼東西的。而實際的值則是交給php_var_serialize
處理。php_var_serialize
若是繼續往下追,可以找到 ext/standard/var.c(直接用 GitHub 搜尋功能就可以找到這個檔案,搜尋功能超方便的),最後就會找到真正在處理的地方:php_var_serialize_intern,裡面會針對不同的形態去呼叫不同的 function。以我們之前存在 session 裡面的 views 來說,是一個數字,所以會跑到這個 function:
追到這邊,就知道為什麼當初 session 序列化之後的結果是
views|i:5;
了。|
拿來分隔 key 跟 value,i 代表著型態,5 代表實際的數字,; 則是結束符號。以上就是 PHP Session 機制的相關原始碼分析,我們稍微看了如何產生 sessionID 以及 session information 如何做序列化。也知道了以預設的狀態來說,cookie 名稱會叫做 PHPSESSID,而且會以檔案的方式來儲存 session 的內容。
最後來分享兩個跟 PHP Session 有關的文章,都十分有趣:
Rails(5.2 版本)
Rails 是一個 Ruby 的 Web 框架,俗稱 Ruby on Rails。會挑這一套是因為我本來就知道它儲存 session 的方法不太一樣。我當初只是好奇 Rails 怎麼生成 sessionID 的,於是就去 GitHub 的 repo 搜尋:session,然後找到這個檔案:rails/actionpack/test/dispatch/session/cookie_store_test.rb,是個測試,但有時候測試其實對找程式碼幫助也很大,因為裡面會出現一堆相關的 function 跟參數。
我那時觀察了一陣子,發現裡面出現了很多次的 session_id,於是就改用這個關鍵字搜尋,找到了 rails/actionpack/lib/action_dispatch/middleware/session/cookie_store.rb,發現裡面的註解把 Rails 的 Session 實作方式寫得一清二楚:
Rails 預設使用 cookie-based session,因為它比其他解決方案都來得快。雖然 cookie 有大小限制,但頂多只會存 flash message 跟 user_id,離 4k 的上限還有一大段距離。
在 Rails 3 裡面 cookie 只會被 signed 不會被加密,意思就是使用者看得到 user_id 但沒辦法改它(就像我們在 express-session 看到的 sessionID 一樣,看得到但不能改)。
而 Rails 4 以後預設就會把 cookie 的值整個加密,什麼都看不到。在測試環境時 Rails 會自動幫你產生一個 secret 來加密,也可以透過 Rails 的設定檔來設定。
在這份檔案中也可以看到有一個 function 叫做
generate_sid
,是拿來產生 sessionID 的。這個 function 存在於 rails/actionpack/lib/action_dispatch/middleware/session/abstract_store.rb:直接呼叫了 Ruby 的函式庫 SecureRandom 來產生亂數並當作 sessionID。
至於在 Cookie 裡面的 key 是什麼,可以經由設定
app.config.session_store
來調整。根據這邊的程式碼:預設值會是
_#{app_name}_session
,例如說我的 app_name 叫做 huli,Cookie 名稱就會是 _huli_session。然後把 session information 實際寫進去 cookie 的地方在 rails/actionpack/lib/action_dispatch/middleware/session/cookie_store.rb:
會呼叫與 cookie 相關的
signed_or_encrypted
來做處理。接著我去搜了一下文件,發現其實官方文件都寫得十分清楚了:
上面這段寫了 sessionID 的產生方式。
這段則是寫說從 Rails 5.2 開始採用 AES GCM 來加密,底下還有一個段落我沒複製,主要是提到之前程式碼註解裡面寫的,Rails 4 前只用 HMAC 來做驗證,而不是加密。
而且我看一看之後發現這文件寫的好棒喔,除了把這些機制說明清楚以外,底下還介紹了我們上一篇提到的 Session Fixation Attack 以及 CSRF。
若是還想深入研究,可以參考 Rails 裡面 Cookie 相關的實作:rails/actionpack/lib/action_dispatch/middleware/cookies.rb,註解裡面有詳細的說明,例如說加密的部分:
往底下追的話就可以看到
EncryptedKeyRotatingCookieJar
的完整程式碼,或你也可以再往下,看看 rails/activesupport/lib/active_support/message_encryptor.rb,負責加密的程式碼長這樣:這裡的 cipher 是從 openssl 來的,所以最底層是使用了 openssl。
整理到這邊應該就差不多了,就不再繼續深入了。
總結
在這篇裡面我們看了三個不同的 Session 儲存方式。第一種是 express-session,把 session information 存在記憶體裡面;第二種是 PHP,存在檔案裡面;最後一種則是 Rails,採用了之前提過的 cookie-based session,將資訊直接加密並且存在 cookie 裡。
在這系列當中,第一篇文章我們理解了概念,第二篇利用讀 RFC 加深印象並重新理解了一次 Session,最後一篇則是直接參考一些主流框架的實作,看看我們之前所提到的 sessionID 應該如何產生,session information 應該存在哪裡,cookie-bases session 又應該如何實作。
寫這系列的初衷就是想讓大家把這些概念一次理解清楚,就不用以後每次碰到都重新查一遍。
最後,希望這系列對大家有幫助,有任何錯誤都可以在底下留言反映。
底下是系列文的完整清單: