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
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,313评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,369评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,916评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,333评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,425评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,481评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,491评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,268评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,719评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,004评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,179评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,832评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,510评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,153评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,402评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,045评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,071评论 2 352

推荐阅读更多精彩内容