Python從入門到摔門(6):Python Web服務器Tornado使用小結

.png

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

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

一、防範跨站僞造請求(Cross-site request forgery,簡稱 CSRF 或 XSRF)

CSRF 的意思簡單來說就是,攻擊者僞造真實用戶來發送請求。

舉例來說,假設某個銀行網站有這樣的 URL: http://bank.example.com/withdraw?amount=1000000&for=Eve 當這個銀行網站的用戶訪問該 URL 時,就會給 Eve 這名用戶一百萬元。用戶當然不會輕易地點擊這個 URL,但是攻擊者可以在其他網站上嵌入一張僞造的圖片,將圖片地址設爲該 URL: <img src="http://bank.example.com/withdraw?amount=1000000&for=Eve"> 那麼當用戶訪問那個惡意網站時,瀏覽器就會對該 URL 發起一個 GET 請求,於是在用戶毫不知情的情況下,一百萬就被轉走了。

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

<form action="http://bank.example.com/withdraw" method="post">
    <p>轉發抽獎送 iPad 啊!</p>
    <input type="hidden" name="amount" value="1000000">
    <input type="hidden" name="for" value="Eve">
    <input type="submit" value="轉發">
</form>

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

要杜絕這種情況,就需要在非 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() 來處理:

$.ajaxSetup({
    beforeSend: function(jqXHR, settings) {
        type = settings.type
        if (type != 'GET' && type != 'HEAD' && type != 'OPTIONS') {
            var pattern = /(.+; *)?_xsrf *= *([^;" ]+)/;
            var xsrf = pattern.exec(document.cookie);
            if (xsrf) {
                jqXHR.setRequestHeader('X-Xsrftoken', xsrf[2]);
            }
        }
}});

此外再順便談談跨站腳本(Cross-site scripting,簡稱 XSS)。和 CSRF 相反的是,XSS 是利用被攻擊網站自身的漏洞,在該網站上注入攻擊者想執行的腳本代碼,讓瀏覽該網站的用戶執行。 不過只要不讓用戶隨意輸入 HTML(例如對 < 和 > 進行轉義),對 HTML 元素的屬性做驗證(例如屬性裏的引號要轉義,src 和 事件處理等屬性不能隨意填寫 JavaScript 代碼等),並檢查 CSS(含 style 屬性)中的 expression 即可避免。

二、防止僞造 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 參數),這個祕鑰可以通過如下代碼來生成: base64.b64encode(uuid.uuid4().bytes + uuid.uuid4().bytes) 這個參數可以隨機生成,但如果同時有多個 Tornado 進程來服務的話,或者有時會重啓的話,還是共用一個常量比較好,並且注意不要泄露。

這個簽名用的是 HMAC 算法,hash 算法採用的是 SHA1。簡單來說就是把 cookie 名、值和時間戳的 hash 作爲簽名,再把“值|時間戳|簽名”作爲新的值。這樣服務器端只要拿祕鑰再次加密,比較簽名是否有變化過即可判斷真僞。 值得一提的是讀源碼時還發現這樣一個函數: def _time_independent_equals(a, b): if len(a) != len(b): return False result = 0 if type(a[0]) is int: # python3 byte strings for x, y in zip(a, b): result |= x ^ y else: # python2 for x, y in zip(a, b): result |= ord(x) ^ ord(y) return result == 0 讀了半天也沒發現和普通的字符串比較有什麼優點,直到看了 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() 方法捕捉,因此覆蓋這個方法即可:

class RequestHandler(tornado.web.RequestHandler):
    def write_error(self, status_code, **kwargs):
        if status_code == 404:
            self.render('404.html')
        elif status_code == 500:
            self.render('500.html')
        else:
            super(RequestHandler, self).write_error(status_code, **kwargs)

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

class PageNotFoundHandler(RequestHandler):
    def get(self):
        raise tornado.web.HTTPError(404)
tornado.web.ErrorHandler = PageNotFoundHandler

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

application = tornado.web.Application([
    # ...
    ('.*', PageNotFoundHandler)
])

四、接着說說處理登錄。

Tornado 提供了 @tornado.web.authenticated 這個裝飾器,在 handler 的 get() 等方法前加上即可。 它會依賴三處代碼: 需要定義 handler 的 get_current_user() 方法,例如:

def get_current_user(self):
    return self.get_secure_cookie('user_id', 0)

它的返回值爲假時,就會跳轉到登錄頁面了。 創建 application 時設置 login_url 參數:

application = tornado.web.Application(
    [
        # ...
    ],
    login_url = '/login'
)

定義 handler 的 get_login_url() 方法。 如果不能使用默認的 login_url 參數(例如普通用戶和管理員需要不同的登錄地址),那麼可以覆蓋 get_login_url() 方法:

class AdminHandler(RequestHandler):
    def get_login_url(self):
        return '/admin/login'

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

class LoginHandler(RequestHandler):
    def get(self):
        if self.get_current_user():
            self.redirect('/')
            return
        self.render('login.html')

    def post(self):
        if self.get_current_user():
            raise tornado.web.HTTPError(403)
        # check username and password
        if success:
            self.redirect(self.get_argument('next', '/'))

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

def authenticated(method):
    """Decorate methods with this to require that the user be logged in."""
    @functools.wraps(method)
    def wrapper(self, *args, **kwargs):
        if not self.current_user:
            if self.request.headers.get('X-Requested-With') == 'XMLHttpRequest': # jQuery 等庫會附帶這個頭
                self.set_header('Content-Type', 'application/json; charset=UTF-8')
                self.write(json.dumps({'success': False, 'msg': u'您的會話已過期,請重新登錄!'}))
                return
            if self.request.method in ("GET", "HEAD"):
                url = self.get_login_url()
                if "?" not in url:
                    if urlparse.urlsplit(url).scheme:
                        # if login url is absolute, make next absolute too
                        next_url = self.request.full_url()
                    else:
                        next_url = self.request.uri
                    url += "?" + urllib.urlencode(dict(next=next_url))
                self.redirect(url)
                return
            raise tornado.web.HTTPError(403)
        return method(self, *args, **kwargs)
    return wrapper

五、然後說下獲取用戶的 IP 地址。

簡單來說,在 handler 的方法裏用 self.request.remote_ip 就能拿到了。 不過如果使用了反向代理,拿到的就是代理的 IP 了,這時候就需要在創建 HTTPServer 時增加 xheaders 的設置了:

if __name__ == '__main__':
    from tornado.httpserver import HTTPServer
    from tornado.netutil import bind_sockets

    sockets = bind_sockets(80)
    server = HTTPServer(application, xheaders=True)
    server.add_sockets(sockets)
    tornado.ioloop.IOLoop.instance().start()

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

if settings.IPV4_ONLY:
    import socket
    sockets = bind_sockets(80, family=socket.AF_INET)
else:
    sockets = bind_sockets(80)

六、最後再提下生產環境下如何提高性能。

Tornado 可以在 HTTPServer 調用 add_sockets() 前創建多個子進程,利用多 CPU 的優勢來處理併發請求。

簡單來說,代碼如下:

if __name__ == '__main__':
    if settings.IPV4_ONLY:
        import socket
        sockets = bind_sockets(80, family=socket.AF_INET)
    else:
        sockets = bind_sockets(80)
    if not settings.DEBUG_MODE:
        import tornado.process
        tornado.process.fork_processes(0) # 0 表示按 CPU 數目創建相應數目的子進程
    server = HTTPServer(application, xheaders=True)
    server.add_sockets(sockets)
    tornado.ioloop.IOLoop.instance().start()

注意這種方式下不能啓用 autoreload 功能(application 在創建時,debug 參數不能爲真)。

點贊關注.jpg

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章