Python Web服务器Tornado使用小结


Posted in Python onMay 06, 2014

首先想说的是它的安全性,这方面确实能让我感受到它的良苦用心。这主要可以分为两点:

一、防范跨站伪造请求(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 参数不能为真)。
Python 相关文章推荐
用python读写excel的方法
Nov 18 Python
Python实现读取目录所有文件的文件名并保存到txt文件代码
Nov 22 Python
Python实现CET查分的方法
Mar 10 Python
介绍Python的Django框架中的QuerySets
Apr 20 Python
在Python的Tornado框架中实现简单的在线代理的教程
May 02 Python
将Python代码打包为jar软件的简单方法
Aug 04 Python
Python常用算法学习基础教程
Apr 13 Python
python print输出延时,让其立刻输出的方法
Jan 07 Python
详解Python Matplotlib解决绘图X轴值不按数组排序问题
Aug 05 Python
Python Pandas对缺失值的处理方法
Sep 27 Python
python操作链表的示例代码
Sep 27 Python
python 用递归实现通用爬虫解析器
Apr 16 Python
Python SQLAlchemy基本操作和常用技巧(包含大量实例,非常好)
May 06 #Python
Python Web开发模板引擎优缺点总结
May 06 #Python
windows系统中python使用rar命令压缩多个文件夹示例
May 06 #Python
Python中使用动态变量名的方法
May 06 #Python
python完成FizzBuzzWhizz问题(拉勾网面试题)示例
May 05 #Python
使用python实现拉钩网上的FizzBuzzWhizz问题示例
May 05 #Python
python通过scapy获取局域网所有主机mac地址示例
May 04 #Python
You might like
PHP 魔术函数使用说明
2010/05/14 PHP
PHP array操作10个小技巧分享
2011/06/23 PHP
php汉字转拼音的示例
2014/02/27 PHP
PHP匿名函数(闭包函数)详解
2019/03/22 PHP
javascript 函数式编程
2007/08/16 Javascript
jQuery ajax BUG:object doesn't support this property or method
2010/07/06 Javascript
jQuery获得内容和属性方法及示例
2013/12/02 Javascript
js实现同一页面多个不同运动效果的方法
2015/04/10 Javascript
解决JS无法调用Controller问题的方法
2015/12/31 Javascript
使用jQuery监听DOM元素大小变化
2016/02/24 Javascript
多功能jQuery树插件zTree实现权限列表简单实例
2016/07/12 Javascript
jquery操作ID带有变量的节点实例
2016/12/07 Javascript
jQuery日程管理控件glDatePicker用法详解
2017/03/29 jQuery
运用jQuery写的验证表单(实例讲解)
2017/07/06 jQuery
jQuery实现键盘回车搜索功能
2017/07/25 jQuery
详解cordova打包成webapp的方法
2017/10/18 Javascript
layer.alert自定义关闭回调事件的方法
2019/09/27 Javascript
vue使用高德地图点击下钻上浮效果的实现思路
2019/10/12 Javascript
uniapp实现可以左右滑动导航栏
2020/10/21 Javascript
Python中多线程thread与threading的实现方法
2014/08/18 Python
Python查找相似单词的方法
2015/03/05 Python
JSONLINT:python的json数据验证库实例解析
2017/11/28 Python
Linux下python3.6.1环境配置教程
2018/09/26 Python
canvas实现圆形进度条动画的示例代码
2017/12/26 HTML / CSS
HTML5之消息通知的使用(Web Notification)
2018/10/30 HTML / CSS
意大利奢侈品综合电商网站:MODES
2019/12/14 全球购物
如何掌握自荐信格式呢
2013/11/19 职场文书
电大毕业自我鉴定
2014/02/03 职场文书
请假条怎么写
2014/04/10 职场文书
《闻一多先生的说和做》教学反思
2014/04/28 职场文书
运动会入场口号
2014/06/07 职场文书
求职教师自荐书
2014/06/19 职场文书
三严三实对照检查材料思想汇报
2014/09/28 职场文书
2014年护士工作总结范文
2014/11/11 职场文书
写给媳妇的检讨书
2015/05/06 职场文书
详细介绍python操作RabbitMq
2022/04/12 Python