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中os.path用法分析
Jan 15 Python
Python中暂存上传图片的方法
Feb 18 Python
Python元组操作实例分析【创建、赋值、更新、删除等】
Jul 24 Python
python 简单搭建阻塞式单进程,多进程,多线程服务的实例
Nov 01 Python
python分析作业提交情况
Nov 22 Python
django如何连接已存在数据的数据库
Aug 14 Python
基于Python实现船舶的MMSI的获取(推荐)
Oct 21 Python
利用PyCharm操作Github(仓库新建、更新,代码回滚)
Dec 18 Python
python wav模块获取采样率 采样点声道量化位数(实例代码)
Jan 22 Python
python 获取当前目录下的文件目录和文件名实例代码详解
Mar 10 Python
keras小技巧——获取某一个网络层的输出方式
May 23 Python
Python环境配置实现pip加速过程解析
Nov 27 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使用GD库创建图片缩略图的方法
2015/06/10 PHP
ThinkPhP+Apache+PHPstorm整合框架流程图解
2020/11/23 PHP
js异或加解密效果代码
2008/06/25 Javascript
清空上传控件input file的值
2010/07/03 Javascript
Js控制弹窗实现在任意分辨率下居中显示
2013/08/01 Javascript
纯JS实现动态时间显示代码
2014/02/08 Javascript
JavaScript参数个数可变的函数举例说明
2014/10/10 Javascript
跟我学习javascript的var预解析与函数声明提升
2015/11/16 Javascript
jquery获取input type=text中的值的各种方式(总结)
2016/12/02 Javascript
vue.js学习之UI组件开发教程
2017/07/03 Javascript
echarts设置图例颜色和地图底色的方法实例
2018/08/01 Javascript
vue.js中ref及$refs的使用方法解析
2019/10/08 Javascript
JavaScript进阶(一)变量声明提升实例分析
2020/05/09 Javascript
基于小程序请求接口wx.request封装的类axios请求
2020/07/02 Javascript
对vue生命周期的深入理解
2020/12/03 Vue.js
[01:01:22]VGJ.S vs OG 2018国际邀请赛淘汰赛BO3 第一场 8.22
2018/08/23 DOTA
在 Django/Flask 开发服务器上使用 HTTPS
2014/07/03 Python
在Python的web框架中中编写日志列表的教程
2015/04/30 Python
python实现在字符串中查找子字符串的方法
2015/07/11 Python
使用Python编写基于DHT协议的BT资源爬虫
2016/03/19 Python
Python中关键字nonlocal和global的声明与解析
2017/03/12 Python
python下载文件记录黑名单的实现代码
2017/10/24 Python
Python代码实现KNN算法
2017/12/20 Python
python3+PyQt5实现自定义窗口部件Counters
2018/04/20 Python
python3.7将代码打包成exe程序并添加图标的方法
2019/10/11 Python
python sorted方法和列表使用解析
2019/11/18 Python
html2canvas截图空白问题的解决
2020/03/24 HTML / CSS
简述Linux文件系统通过i节点把文件的逻辑结构和物理结构转换的工作过程
2012/04/17 面试题
同步和异步有何异同,在什么情况下分别使用他们
2013/04/09 面试题
机械工程师求职自我评价
2013/09/23 职场文书
大型公益活动策划方案
2014/08/20 职场文书
不尊敬老师检讨书范文
2014/11/19 职场文书
年会主持人开场白台词
2015/05/29 职场文书
Java Shutdown Hook场景使用及源码分析
2021/06/15 Java/Android
pycharm部署django项目到云服务器的详细流程
2021/06/29 Python
前端canvas中物体边框和控制点的实现示例
2022/08/05 Javascript