Tornado 使用經驗

發表於2016-01-05

最近在做一個網站的後端開發。因為初期只有我一個人做,所以技術選擇上很自由。在 web 伺服器上我選擇了Tornado。雖然曾經也讀過它的原始碼,並做過一些小的 demo,但畢竟這是第一次在工作中使用,難免又發現了一些值得分享的東西。

首先想說的是它的安全性,這方面確實能讓我感受到它的良苦用心。這主要可以分為兩點:

  1. 防範跨站偽造請求(Cross-site request forgery,簡稱 CSRF 或 XSRF)。
    CSRF 的意思簡單來說就是,攻擊者偽造真實使用者來傳送請求。舉例來說,假設某個銀行網站有這樣的 URL: 

    http://bank.example.com/withdraw?amount=1000000&for=Eve

    當這個銀行網站的使用者訪問該 URL 時,就會給 Eve 這名使用者一百萬元。使用者當然不會輕易地點選這個 URL,但是攻擊者可以在其他網站上嵌入一張偽造的圖片,將圖片地址設為該 URL:

    那麼當使用者訪問那個惡意網站時,瀏覽器就會對該 URL 發起一個 GET 請求,於是在使用者毫不知情的情況下,一百萬就被轉走了。

    要防範上述攻擊很簡單,不允許通過 GET 請求來執行更改操作(例如轉賬)即可。不過其他型別的請求照樣也不安全,假如攻擊者構造這樣一個表單:

    不明真相的使用者點了下“轉發”按鈕,結果錢就被轉走了…

    要杜絕這種情況,就需要在非 GET 請求時新增一個攻擊者無法偽造的欄位,處理請求時驗證這個欄位是否修改過。
    Tornado 的處理方法很簡單,在請求中增加了一個隨機生成的 _xsrf 欄位,並且 cookie 中也增加這個欄位,在接收請求時,比較這 2 個欄位的值。
    由於非本站的網頁是不能獲取或修改 cookie 的,這就保證了 _xsrf 無法被第三方網站偽造(HTTP 嗅探例外)。
    當然,使用者自己是可以隨意獲取和修改 cookie 的,不過這已經不屬於 CSRF 的範疇了:使用者自己偽造自己所做的事情,當然由他自己來承擔。

    要使用該功能的話,需要在生成 tornado.web.Application 物件時,加上 xsrf_cookies=True 引數,這會給使用者生成一個名為 _xsrf 的 cookie 欄位。
    此外還需要你在非 GET 請求的表單里加上 xsrf_form_html(),如果不用 Tornado 的模板的話,在 tornado.web.RequestHandler 內部可以用 self.xsrf_form_html() 來生成。

    對於 AJAX 請求來說,基本上是不需要擔心跨站的,所以 Tornado 1.1.1 以前的版本並不對帶有 X-Requested-With: XMLHTTPRequest 的請求做驗證。
    後來 Google 的工程師指出,惡意的瀏覽器外掛可以偽造跨域 AJAX 請求,所以也應該進行驗證。對此我不置可否,因為瀏覽器外掛的許可權可以非常大,偽造 cookie 或是直接提交表單都行。
    不過解決辦法仍然要說,其實只要從 cookie 中獲取 _xsrf 欄位,然後在 AJAX 請求時加上這個引數,或者放在 X-Xsrftoken 或 X-Csrftoken 請求頭裡即可。嫌麻煩的話,可以用 jQuery 的 $.ajaxSetup() 來處理:

    不過只要不讓使用者隨意輸入 HTML(例如對 < 和 > 進行轉義),對 HTML 元素的屬性做驗證(例如屬性裡的引號要轉義,src 和 事件處理等屬性不能隨意填寫 JavaScript 程式碼等),並檢查 CSS(含 style 屬性)中的 expression 即可避免。

  2. 防止偽造 cookie。
    前面提到的 CSRF 和 XSS 都是攻擊者在使用者不知情的情況下,冒用他的名義來進行操作;而偽造 cookie 則是攻擊者自己主動偽造其他使用者來進行操作。
    舉例來說,假設網站的登入驗證就是檢查 cookie 中的使用者名稱,只要符合的話,就認為該使用者已登入。那麼攻擊者只要在 cookie 中設定 username=admin 之類的值,就可以冒充管理員來操作了。要防止 cookie 被偽造,首先需要提到設定 cookie 時的兩個引數:secure 和 httponly。這兩個引數並不在 tornado.web.RequestHandler.set_cookie() 的引數列表裡,而是作為關鍵字引數傳遞,並在 Cookie.Morsel._reserved 中定義的。
    前者是指這個 cookie 只能通過安全連線傳遞(即 HTTPS),這就使得嗅探者無法截獲該 cookie;後者則要求其只能在 HTTP 協議下訪問(即無法通過 JavaScript 來獲取 document.cookie 中的該欄位,並且設定後也不會通過 HTTP 協議向伺服器傳送),這便使得攻擊者無法簡單地通過 JavaScript 指令碼來偽造 cookie。 

    不過對於惡意的攻擊者,這兩個引數並不能杜絕 cookie 被偽造。為此就需要對 cookie 做個簽名,一旦被修改,伺服器端可以判斷出來。
    Tornado 中提供了 set_secure_cookie() 這個方法來對 cookie 做簽名。簽名時需要提供一串祕鑰(生成 tornado.web.Application 物件時的 cookie_secret 引數),這個祕鑰可以通過如下程式碼來生成:

    這個引數可以隨機生成,但如果同時有多個 Tornado 程式來服務的話,或者有時會重啟的話,還是共用一個常量比較好,並且注意不要洩露。

    這個簽名用的是 HMAC 演算法,hash 演算法採用的是 SHA1。簡單來說就是把 cookie 名、值和時間戳的 hash 作為簽名,再把“值|時間戳|簽名”作為新的值。這樣伺服器端只要拿祕鑰再次加密,比較簽名是否有變化過即可判斷真偽。
    值得一提的是讀原始碼時還發現這樣一個函式:

    讀了半天也沒發現和普通的字串比較有什麼優點,直到看了 StackOverflow 上的答案才知道:為了避免攻擊者通過測試比較時間來判斷正確的位數,這個函式讓比較的時間比較恆定,也就杜絕了這種情況。(話說這答案看得我各種佩服啊,搞安全的專家果然不是我那麼膚淺的…)

接著是繼承 tornado.web.RequestHandler。
在執行流程上,tornado.web.Application 會根據 URL 尋找一個匹配的 RequestHandler 類,並初始化它。它的 __init__() 方法會呼叫 initialize() 方法,所以只要覆蓋後者即可,並且不需要呼叫父類的 initialize()。
接著根據不同的 HTTP 方法尋找該 handler 的 get/post() 等方法,並在執行前執行 prepare()。這些方法都不會主動呼叫父類的,因此有需要時,自行呼叫吧。
最後會呼叫 handler 的 finish() 方法,這個方法最好別覆蓋。它會呼叫 on_finish() 方法,它可以被覆蓋,用於處理一些善後的事情(例如關閉資料庫連線),但不能再向瀏覽器傳送資料了(因為 HTTP 響應已傳送,連線也可能已被關閉)。

順便說下怎麼處理錯誤頁面。
簡單來說,執行 RequestHandler 的 _execute() 方法(內部依次執行 prepare()、get() 和 finish() 等方法)時,任何未捕捉的錯誤都會被它的 write_error() 方法捕捉,因此覆蓋這個方法即可:

由於歷史原因,你也可以覆蓋 get_error_html() 方法,不過不被推薦。
此外,你還可能沒到 _execute() 方法就出錯了。
例如 initialize() 方法丟擲了一個未捕捉的異常,這個異常會被 IOStream 捕捉到,然後直接關閉連線,不能向使用者輸出任何錯誤頁面。
再比如沒有找到一個能處理該請求的 handler,就會用 tornado.web.ErrorHandler 去處理 404 錯誤。這種情況可以替換這個類來實現自定義錯誤頁面:

另一種方法就是在 Application 的 handlers 引數的最後,加上一個能捕捉任何 URL 的 handler:

接著說說處理登入。
Tornado 提供了 @tornado.web.authenticated 這個裝飾器,在 handler 的 get() 等方法前加上即可。
它會依賴三處程式碼:

  1. 需要定義 handler 的 get_current_user() 方法,例如:

    它的返回值為假時,就會跳轉到登入頁面了。
  2. 建立 application 時設定 login_url 引數:
  3. 定義 handler 的 get_login_url() 方法。
    如果不能使用預設的 login_url 引數(例如普通使用者和管理員需要不同的登入地址),那麼可以覆蓋 get_login_url() 方法: 

順帶一提,跳轉到登入頁後時會附帶一個 next 引數,指向登入前訪問的網址。為達到更好的使用者體驗,需要在登入後跳轉到該網址:

此外,我很多地方都使用了 AJAX 技術,而前端懶得去處理 403 錯誤,所以我只能改造一下 authenticated() 了:

然後說下獲取使用者的 IP 地址。
簡單來說,在 handler 的方法裡用 self.request.remote_ip 就能拿到了。
不過如果使用了反向代理,拿到的就是代理的 IP 了,這時候就需要在建立 HTTPServer 時增加 xheaders 的設定了:

此外,我只需要處理 IPv4,但本地測試時會拿到 ::1 這種 IPv6 地址,所以還需要設定一下:

最後再提下生產環境下如何提高效能。Tornado 可以在 HTTPServer 呼叫 add_sockets() 前建立多個子程式,利用多 CPU 的優勢來處理併發請求。
簡單來說,程式碼如下:

注意這種方式下不能啟用 autoreload 功能(application 在建立時,debug 引數不能為真)。

相關文章