第2章 Flask与HTTP
在第1章,我们已经了解了Flask的基本知识,如果想要进一步开发更复杂的Flask应用,我们就得了解Flask与HTTP协议的交互方式。HTTP(Hypertext Transfer Protocol,超文本传输协议)定义了服务器和客户端之间信息交流的格式和传递方式,它是万维网(World Wide Web)中数据交换的基础。
在这一章,我们会了解Flask处理请求和响应的各种方式,并对HTTP协议以及其他非常规HTTP请求进行简单的介绍。虽然本章的内容很重要,但鉴于内容有些晦涩难懂,如果感到困惑也不用担心,本章介绍的内容你会在后面的实践中逐渐理解和熟悉。如果你愿意,也可以临时跳过本章,等到学习完本书第一部分再回来重读。
附注
HTTP的详细定义在RFC 7231~7235中可以看到。RFC(Request For Comment,请求评议)是一系列关于互联网标准和信息的文件,可以将其理解为互联网(Internet)的设计文档。完整的RFC列表可以在这里看到:https://tools.ietf.org/rfc/。
本章的示例程序在helloflask/demos/http目录下,确保当前工作目录在helloflask/demos/http下并激活了虚拟环境,然后执行flask run命令运行程序:
$ cd demos/http $ flask run
注意
第一部分的示例程序都会运行在本地机的5000端口,在运行新的示例程序前,请确保没有其他程序在运行。
2.1 请求响应循环
为了更贴近现实,我们以一个真实的URL为例:
http://helloflask.com/hello
当我们在浏览器中的地址栏中输入这个URL,然后按下Enter时,稍等片刻,浏览器会显示一个问候页面。这背后到底发生了什么?你一定可以猜想到,这背后也有一个类似我们第1章编写的程序运行着。它负责接收用户的请求,并把对应的内容返回给客户端,显示在用户的浏览器上。事实上,每一个Web应用都包含这种处理模式,即“请求-响应循环(Request-Response Cycle)”:客户端发出请求,服务器端处理请求并返回响应,如图2-1所示。
图2-1 请求响应循环示意图
附注
客户端(Client Side)是指用来提供给用户的与服务器通信的各种软件。在本书中,客户端通常指Web浏览器(后面简称浏览器),比如Chrome、Firefox、IE等;服务器端(Server Side)则指为用户提供服务的服务器,也是我们的程序运行的地方。
这是每一个Web程序的基本工作模式,如果再进一步,这个模式又包含着更多的工作单元,图2-2展示了一个Flask程序工作的实际流程。
图2-2 Flask Web程序工作流程
从图2-2中可以看出,HTTP在整个流程中起到了至关重要的作用,它是客户端和服务器端之间沟通的桥梁。
当用户访问一个URL,浏览器便生成对应的HTTP请求,经由互联网发送到对应的Web服务器。Web服务器接收请求,通过WSGI将HTTP格式的请求数据转换成我们的Flask程序能够使用的Python数据。在程序中,Flask根据请求的URL执行对应的视图函数,获取返回值生成响应。响应依次经过WSGI转换生成HTTP响应,再经由Web服务器传递,最终被发出请求的客户端接收。浏览器渲染响应中包含的HTML和CSS代码,并执行Java Script代码,最终把解析后的页面呈现在用户浏览器的窗口中。
提示
关于WSGI的更多细节,我们会在第16章进行详细介绍。
提示
这里的服务器指的是处理请求和响应的Web服务器,比如我们上一章介绍的开发服务器,而不是指物理层面上的服务器主机。
2.2 HTTP请求
URL是一个请求的起源。不论服务器是运行在美国洛杉矶,还是运行在我们自己的电脑上,当我们输入指向服务器所在地址的URL,都会向服务器发送一个HTTP请求。一个标准的URL由很多部分组成,以下面这个URL为例:
http://helloflask.com/hello?name=Grey
这个URL的各个组成部分如表2-1所示。
表2-1 URL组成部分
附注
这个URL后面的?name=Grey部分是查询字符串(query string)。URL中的查询字符串用来向指定的资源传递参数。查询字符串从问号?开始,以键值对的形式写出,多个键值对之间使用&分隔。
2.2.1 请求报文
当我们在浏览器中访问这个URL时,随之产生的是一个发向http://helloflask.com所在服务器的请求。请求的实质是发送到服务器上的一些数据,这种浏览器与服务器之间交互的数据被称为报文(message),请求时浏览器发送的数据被称为请求报文(request message),而服务器返回的数据被称为响应报文(response message)。
请求报文由请求的方法、URL、协议版本、首部字段(header)以及内容实体组成。前面的请求产生的请求报文示意如表2-2所示。
表2-2 请求报文示意表
如果你想看真实的HTTP报文,可以在浏览器中向任意一个有效的URL发起请求,然后在浏览器的开发者工具(F12)里的Network标签中看到URL对应资源加载的所有请求列表,单击任一个请求条目即可看到报文信息,图2-3是使用Chrome访问本地示例程序的示例。
图2-3 在Chrome浏览器中查看请求和响应报文
报文由报文首部和报文主体组成,两者由空行分隔,请求报文的主体一般为空。如果URL中包含查询字符串,或是提交了表单,那么报文主体将会是查询字符串和表单数据。
HTTP通过方法来区分不同的请求类型。比如,当你直接访问一个页面时,请求的方法是GET;当你在某个页面填写了表单并提交时,请求方法则通常为POST。表2-3是常见的几种HTTP方法类型。
表2-3 常见的HTTP方法
报文首部包含了请求的各种信息和设置,比如客户端的类型、是否设置缓存、语言偏好等。
附注
HTTP中可用的首部字段列表可以在https://www.iana.org/assignments/message-headers/message-headers.xhtml看到。请求方法的详细列表和说明可以在RFC 7231(https://tools.ietf.org/html/rfc7231)中看到。
如果运行了示例程序,那么当你在浏览器中访问http://127.0.0.1:5000/hello时,开发服务器会在命令行中输出一条记录日志,其中包含请求的主要信息:
127.0.0.1- - [02/Aug/2017 09:51:37] "GET /hello HTTP/1.1" 200-
2.2.2 Request对象
现在该让Flask的请求对象request出场了,这个请求对象封装了从客户端发来的请求报文,我们能从它获取请求报文中的所有数据。
注意
请求解析和响应封装实际上大部分是由Werkzeug完成的,Flask子类化Werkzeug的请求(Request)和响应(Response)对象并添加了和程序相关的特定功能。在这里为了方便理解,我们先略过不谈。在第16章,我们会详细了解Flask的工作原理。
和上一节一样,我们先从URL说起。假设请求的URL是http://helloflask.com/hello?name=Grey,当Flask接收到请求后,请求对象会提供多个属性来获取URL的各个部分,常用的属性如表2-4所示。
表2-4 使用request的属性获取请求URL
除了URL,请求报文中的其他信息都可以通过request对象提供的属性和方法获取,其中常用的部分如表2-5所示。
表2-5 request对象常用的属性和方法
提示
Werkzeug的Mutli Dict类是字典的子类,它主要实现了同一个键对应多个值的情况。比如一个文件上传字段可能会接收多个文件。这时就可以通过getlist()方法来获取文件对象列表。而Immutable Multi Dict类继承了Mutli Dict类,但其值不可更改。更多内容可访问Werkzeug相关数据结构章节http://werkzeug.pocoo.org/docs/latest/datastructures/。
在我们的示例程序中实现了同样的功能。当你访问http://localhost:5000/hello?name=Grey时,页面加载后会显示“Hello,Grey!”。这说明处理这个URL的视图函数从查询字符串中获取了查询参数name的值,如代码清单2-1所示。
代码清单2-1 获取请求URL中的查询字符串
from flask import Flask, request app = Flask(__name__) @app.route('/hello') def hello(): name = request.args.get('name', 'Flask') # 获取查询参数name的值 return '<h1>Hello, %s!<h1>' % name # 插入到返回值中
注意
上面的示例代码包含安全漏洞,在现实中我们要避免直接将用户传入的数据直接作为响应返回,在本章的末尾我们将介绍这个安全漏洞的具体细节和防范措施。
需要注意的是,和普通的字典类型不同,当我们从request对象的类型为Mutli Dict或Immutable Multi Dict的属性(比如files、form、args)中直接使用键作为索引获取数据时(比如request.args['name']),如果没有对应的键,那么会返回HTTP 400错误响应(Bad Request,表示请求无效),而不是抛出Key Error异常,如图2-4所示。为了避免这个错误,我们应该使用get()方法获取数据,如果没有对应的值则返回None;get()方法的第二个参数可以设置默认值,比如requset.args.get('name','Human')。
图2-4400错误响应
提示
如果开启了调试模式,那么会抛出Bad Request Key Error异常并显示对应的错误堆栈信息,而不是常规的400响应。
2.2.3 在Flask中处理请求
URL是指向网络上资源的地址。在Flask中,我们需要让请求的URL匹配对应的视图函数,视图函数返回值就是URL对应的资源。
1.路由匹配
为了便于将请求分发到对应的视图函数,程序实例中存储了一个路由表(app.url_map),其中定义了URL规则和视图函数的映射关系。当请求发来后,Flask会根据请求报文中的URL(path部分)来尝试与这个表中的所有URL规则进行匹配,调用匹配成功的视图函数。如果没有找到匹配的URL规则,说明程序中没有处理这个URL的视图函数,Flask会自动返回404错误响应(Not Found,表示资源未找到)。你可以尝试在浏览器中访问http://localhost:5000/nothing,因为我们的程序中没有视图函数负责处理这个URL,所以你会得到404响应,如图2-5所示。
图2-5 404错误响应
如果你经常上网,那么肯定会对这个错误代码相当熟悉,它表示请求的资源没有找到。和前面提及的400错误响应一样,这类错误代码被称为HTTP状态码,用来表示响应的状态,具体会在下面详细讨论。
当请求的URL与某个视图函数的URL规则匹配成功时,对应的视图函数就会被调用。使用flask routes命令可以查看程序中定义的所有路由,这个列表由app.url_map解析得到:
$ flask routes Endpoint Methods Rule -------- ------- ----------------------- hello GET /hello go_back GET /goback/<int:age> hi GET /hi ... static GET /static/<path:filename>
在输出的文本中,我们可以看到每个路由对应的端点(Endpoint)、HTTP方法(Methods)和URL规则(Rule),其中static端点是Flask添加的特殊路由,用来访问静态文件,具体我们会在第3章学习。
2.设置监听的HTTP方法
在上一节通过flask routes命令打印出的路由列表可以看到,每一个路由除了包含URL规则外,还设置了监听的HTTP方法。GET是最常用的HTTP方法,所以视图函数默认监听的方法类型就是GET,HEAD、OPTIONS方法的请求由Flask处理,而像DELETE、PUT等方法一般不会在程序中实现,在后面我们构建Web API时才会用到这些方法。
我们可以在app.route()装饰器中使用methods参数传入一个包含监听的HTTP方法的可迭代对象。比如,下面的视图函数同时监听GET请求和POST请求:
@app.route('/hello', methods=['GET', 'POST']) def hello(): return '<h1>Hello, Flask!</h1>'
当某个请求的方法不符合要求时,请求将无法被正常处理。比如,在提交表单时通常使用POST方法,而如果提交的目标URL对应的视图函数只允许GET方法,这时Flask会自动返回一个405错误响应(Method Not Allowed,表示请求方法不允许),如图2-6所示。
图2-6405错误响应
通过定义方法列表,我们可以为同一个URL规则定义多个视图函数,分别处理不同HTTP方法的请求,这在本书第二部分构建Web API时会用到这个特性。
3.URL处理
从前面的路由列表中可以看到,除了/hello,这个程序还包含许多URL规则,比如和go_back端点对应的/goback/<int:year>。现在请尝试访问http://localhost:5000/goback/34,在URL中加入一个数字作为时光倒流的年数,你会发现加载后的页面中有通过传入的年数计算出的年份:“Welcome to 1984!”。仔细观察一下,你会发现URL规则中的变量部分有一些特别,<int:year>表示为year变量添加了一个int转换器,Flask在解析这个URL变量时会将其转换为整型。URL中的变量部分默认类型为字符串,但Flask提供了一些转换器可以在URL规则里使用,如表2-6所示。
表2-6 Flask内置的URL变量转换器
转换器通过特定的规则指定,即“<转换器:变量名>”。<int:year>把year的值转换为整数,因此我们可以在视图函数中直接对year变量进行数学计算:
@app.route('goback/<int:year>') def go_back(year): return '<p>Welcome to %d! </p>' % (2018- year)
默认的行为不仅仅是转换变量类型,还包括URL匹配。在这个例子中,如果不使用转换器,默认year变量会被转换成字符串,为了能够在Python中计算天数,我们需要使用int()函数将year变量转换成整型。但是如果用户输入的是英文字母,就会出现转换错误,抛出Value Error异常,我们还需要手动验证;使用了转换器后,如果URL中传入的变量不是数字,那么会直接返回404错误响应。比如,你可以尝试访问http://localhost:5000/goback/tang。
在用法上唯一特别的是any转换器,你需要在转换器后添加括号来给出可选值,即“<any(value1,value2,...):变量名>”,比如:
@app.route('/colors/<any(blue, white, red):color>') def three_colors(color): return '<p>Love is patient and kind. Love is not jealous or boastful or proud or rude.</p>'
当你在浏览器中访问http://localhost:5000/colors/<color>时,如果将<color>部分替换为any转换器中设置的可选值以外的任意字符,均会获得404错误响应。
如果你想在any转换器中传入一个预先定义的列表,可以通过格式化字符串的方式(使用%或是format()函数)来构建URL规则字符串,比如:
colors = ['blue', 'white', 'red'] @app.route('/colors/<any(%s):color>' % str(colors)[1:-1]) ...
2.2.4 请求钩子
有时我们需要对请求进行预处理(preprocessing)和后处理(postprocessing),这时可以使用Flask提供的一些请求钩子(Hook),它们可以用来注册在请求处理的不同阶段执行的处理函数(或称为回调函数,即Callback)。这些请求钩子使用装饰器实现,通过程序实例app调用,用法很简单:以before_request钩子(请求之前)为例,当你对一个函数附加了app.before_request装饰器后,就会将这个函数注册为before_request处理函数,每次执行请求前都会触发所有before_request处理函数。Flask默认实现的五种请求钩子如表2-7所示。
表2-7 请求钩子
这些钩子使用起来和app.route()装饰器基本相同,每个钩子可以注册任意多个处理函数,函数名并不是必须和钩子名称相同,下面是一个基本示例:
@app.before_request def do_something(): pass # 这里的代码会在每个请求处理前执行
假如我们创建了三个视图函数A、B、C,其中视图C使用了after_this_request钩子,那么当请求A进入后,整个请求处理周期的请求处理函数调用流程如图2-7所示。
图2-7 请求处理函数调用示意图
下面是请求钩子的一些常见应用场景:
❑ before_first_request:在玩具程序中,运行程序前我们需要进行一些程序的初始化操作,比如创建数据库表,添加管理员用户。这些工作可以放到使用before_first_request装饰器注册的函数中。
❑ before_request:比如网站上要记录用户最后在线的时间,可以通过用户最后发送的请求时间来实现。为了避免在每个视图函数都添加更新在线时间的代码,我们可以仅在使用before_request钩子注册的函数中调用这段代码。
❑ after_request:我们经常在视图函数中进行数据库操作,比如更新、插入等,之后需要将更改提交到数据库中。提交更改的代码就可以放到after_request钩子注册的函数中。
另一种常见的应用是建立数据库连接,通常会有多个视图函数需要建立和关闭数据库连接,这些操作基本相同。一个理想的解决方法是在请求之前(before_request)建立连接,在请求之后(teardown_request)关闭连接。通过在使用相应的请求钩子注册的函数中添加代码就可以实现。这很像单元测试中的set Up()方法和tear Down()方法。
注意
after_request钩子和after_this_request钩子必须接收一个响应类对象作为参数,并且返回同一个或更新后的响应对象。
2.3 HTTP响应
在Flask程序中,客户端发出的请求触发相应的视图函数,获取返回值会作为响应的主体,最后生成完整的响应,即响应报文。
2.3.1 响应报文
响应报文主要由协议版本、状态码(status code)、原因短语(reason phrase)、响应首部和响应主体组成。以发向localhost:5000/hello的请求为例,服务器生成的响应报文示意如表2-8所示。
表2-8 响应报文
响应报文的首部包含一些关于响应和服务器的信息,这些内容由Flask生成,而我们在视图函数中返回的内容即为响应报文中的主体内容。浏览器接收到响应后,会把返回的响应主体解析并显示在浏览器窗口上。
HTTP状态码用来表示请求处理的结果,表2-9是常见的几种状态码和相应的原因短语。
表2-9 常见的HTTP状态码
提示
当关闭调试模式时,即FLASK_ENV使用默认值production,如果程序出错,Flask会自动返回500错误响应;而调试模式下则会显示调试信息和错误堆栈。
附注
响应状态码的详细列表和说明可以在RFC 7231(https://tools.ietf.org/html/rfc7231)中看到。
2.3.1 在Flask中生成响应
响应在Flask中使用Response对象表示,响应报文中的大部分内容由服务器处理,大多数情况下,我们只负责返回主体内容。
根据我们在上一节介绍的内容,Flask会先判断是否可以找到与请求URL相匹配的路由,如果没有则返回404响应。如果找到,则调用对应的视图函数,视图函数的返回值构成了响应报文的主体内容,正确返回时状态码默认为200。Flask会调用make_response()方法将视图函数返回值转换为响应对象。
完整地说,视图函数可以返回最多由三个元素组成的元组:响应主体、状态码、首部字段。其中首部字段可以为字典,或是两元素元组组成的列表。
比如,普通的响应可以只包含主体内容:
@app.route('/hello') def hello(): ... return '<h1>Hello, Flask! </h1>' 默认的状态码为200,下面指定了不同的状态码: @app.route('/hello') def hello(): ... return '<h1>Hello, Flask! </h1>', 201
有时你会想附加或修改某个首部字段。比如,要生成状态码为3XX的重定向响应,需要将首部中的Location字段设置为重定向的目标URL:
@app.route('/hello') def hello(): ... return '', 302, {'Location', 'http://www.example.com'}
现在访问http://localhost:5000/hello,会重定向到http://www.example.com。在多数情况下,除了响应主体,其他部分我们通常只需要使用默认值即可。
1.重定向
如果你访问http://localhost:5000/hi,你会发现页面加载后地址栏中的URL变为了http://localhost:5000/hello。这种行为被称为重定向(Redirect),你可以理解为网页跳转。在上一节的示例中,状态码为302的重定向响应的主体为空,首部中需要将Location字段设为重定向的目标URL,浏览器接收到重定向响应后会向Location字段中的目标URL发起新的GET请求,整个流程如图2-8所示。
图2-8 重定向流程示意图
在Web程序中,我们经常需要进行重定向。比如,当某个用户在没有经过认证的情况下访问需要登录后才能访问的资源,程序通常会重定向到登录页面。
对于重定向这一类特殊响应,Flask提供了一些辅助函数。除了像前面那样手动生成302响应,我们可以使用Flask提供的redirect()函数来生成重定向响应,重定向的目标URL作为第一个参数。前面的例子可以简化为:
from flask import Flask, redirect # ... @app.route('/hello') def hello(): return redirect('http://www.example.com')
提示
使用redirect()函数时,默认的状态码为302,即临时重定向。如果你想修改状态码,可以在redirect()函数中作为第二个参数或使用code关键字传入。
如果要在程序内重定向到其他视图,那么只需在redirect()函数中使用url_for()函数生成目标URL即可,如代码清单2-2所示。
代码清单2-2 http/app.py:重定向到其他视图
from flask import Flask, redirect, url_for ... @app.route('/hi') def hi(): ... return redierct(url_for('hello')) # 重定向到/hello @app.route('/hello') def hello(): ...
2.错误响应
如果你访问http://localhost:5000/brew/coffee,会获得一个418错误响应(I'm a teapot),如图2-9所示。
图2-9418错误响应
附注
418错误响应由IETF(Internet Engineering Task Force,互联网工程任务组)在1998年愚人节发布的HTCPCP(Hyper Text Coffee Pot Control Protocol,超文本咖啡壶控制协议)中定义(玩笑),当一个控制茶壶的HTCPCP收到BREW或POST指令要求其煮咖啡时应当回传此错误。
大多数情况下,Flask会自动处理常见的错误响应。HTTP错误对应的异常类在Werkzeug的werkzeug.exceptions模块中定义,抛出这些异常即可返回对应的错误响应。如果你想手动返回错误响应,更方便的方法是使用Flask提供的abort()函数。
在abort()函数中传入状态码即可返回对应的错误响应,代码清单2-3中的视图函数返回404错误响应。
代码清单2-3 http/app.py:返回404错误响应
from flask import Flask, abort ... @app.route('/404') def not_found(): abort(404)
提示
abort()函数前不需要使用return语句,但一旦abort()函数被调用,abort()函数之后的代码将不会被执行。
附注
虽然我们有必要返回正确的状态码,但这不是必须的。比如,当某个用户没有权限访问某个资源时,返回404错误要比403错误更加友好。
2.3.2 响应格式
在HTTP响应中,数据可以通过多种格式传输。大多数情况下,我们会使用HTML格式,这也是Flask中的默认设置。在特定的情况下,我们也会使用其他格式。不同的响应数据格式需要设置不同的MIME类型,MIME类型在首部的Content-Type字段中定义,以默认的HTML类型为例:
Content-Type: text/html; charset=utf-8
附注
MIME类型(又称为media type或content type)是一种用来标识文件类型的机制,它与文件扩展名相对应,可以让客户端区分不同的内容类型,并执行不同的操作。一般的格式为“类型名/子类型名”,其中的子类型名一般为文件扩展名。比如,HTML的MIME类型为“text/html”,png图片的MIME类型为“image/png”。完整的标准MIME类型列表可以在这里看到:https://www.iana.org/assignments/media-types/media-types.xhtml。
如果你想使用其他MIME类型,可以通过Flask提供的make_response()方法生成响应对象,传入响应的主体作为参数,然后使用响应对象的mimetype属性设置MIME类型,比如:
from flask import make_response @app.route('/foo') def foo(): response = make_response('Hello, World! ') response.mimetype = 'text/plain' return response
你也可以直接设置首部字段,比如response.headers['Content-Type'] = 'text/xml; charset=utf-8'。但操作mimetype属性更加方便,而且不用设置字符集(charset)选项。
常用的数据格式有纯文本、HTML、XML和JSON,下面我们分别对这几种数据进行简单的介绍和分析。为了对不同的数据类型进行对比,我们将会用不同的数据类型来表示一个便签的内容:Jane写给Peter的一个提醒。
1.纯文本
MIME类型:text/plain
示例:
Note to: Peter from: Jane heading: Reminder body: Don't forget the party!
事实上,其他几种格式本质上都是纯文本。比如同样是一行包含HTML标签的文本“<h1>Hello,Flask!</h1>”,当MIME类型设置为纯文本时,浏览器会以文本形式显示“<h1>Hello,Flask!</h1>”;当MIME类型声明为text/html时,浏览器则会将其作为标题1样式的HTML代码渲染。
2.HTML
MIME类型:text/html
示例:
<! DOCTYPE html> <html> <head></head> <body> <h1>Note</h1> <p>to: Peter</p> <p>from: Jane</p> <p>heading: Reminder</p> <p>body: <strong>Don't forget the party! </strong></p> </body> </html>
HTML(https://www.w3.org/html/)指Hypertext Markup Language(超文本标记语言),是最常用的数据格式,也是Flask返回响应的默认数据类型。从我们在本书一开始的最小程序中的视图函数返回的字符串,到我们后面会学习的HTML模板,都是HTML。当数据类型为HTML时,浏览器会自动根据HTML标签以及样式类定义渲染对应的样式。
因为HTML常常包含丰富的信息,我们可以直接将HTML嵌入页面中,处理起来比较方便。因此,在普通的HTTP请求中我们使用HTTP作为响应的内容,这也是默认的数据类型。
3.XML
MIME类型:application/xml
示例:
<? xml version="1.0" encoding="UTF-8"? > <note> <to>Peter</to> <from>Jane</from> <heading>Reminder</heading> <body>Don't forget the party! </body> </note>
XML(https://www.w3.org/XML/)指Extensible Markup Language(可扩展标记语言),它是一种简单灵活的文本格式,被设计用来存储和交换数据。XML的出现主要就是为了弥补HTML的不足:对于仅仅需要数据的请求来说,HTML提供的信息太过丰富了,而且不易于重用。XML和HTML一样都是标记性语言,使用标签来定义文本,但HTML中的标签用于显示内容,而XML中的标签只用于定义数据。XML一般作为AJAX请求的响应格式,或是Web API的响应格式。
4.JSON
MIME类型:application/json
示例:
{ "note":{ "to":"Peter", "from":"Jane", "heading":"Remider", "body":"Don't forget the party! " } }
JSON(http://json.org/)指Java Script Object Notation(Java Script对象表示法),是一种流行的、轻量的数据交换格式。它的出现又弥补了XML的诸多不足:XML有较高的重用性,但XML相对于其他文档格式来说体积稍大,处理和解析的速度较慢。JSON轻量,简洁,容易阅读和解析,而且能和Web默认的客户端语言Java Script更好地兼容。JSON的结构基于“键值对的集合”和“有序的值列表”,这两种数据结构类似Python中的字典(dictionary)和列表(list)。正是因为这种通用的数据结构,使得JSON在同样基于这些结构的编程语言之间交换成为可能。
提示
示例程序中提供了这一资源的不同格式响应,你可以访问http://localhost:5000/note/<content_type>,通过将content_type的值依次更改为text、html、xml和json来获取不同格式的响应。比如,访问http://localhost:5000/note/text将得到纯文本格式的响应。
Flask通过引入Python标准库中的json模块(或simplejson,如果可用)为程序提供了JSON支持。你可以直接从Flask中导入json对象,然后调用dumps()方法将字典、列表或元组序列化(serialize)为JSON字符串,再使用前面介绍的方法修改MIME类型,即可返回JSON响应,如下所示:
from flask import Flask, make_response, json ... @app.route('/foo') def foo(): data = { 'name':'Grey Li', 'gender':'male' } response = make_response(json.dumps(data)) response.mimetype = 'application/json' return response
不过我们一般并不直接使用json模块的dumps()、load()等方法,因为Flask通过包装这些方法提供了更方便的jsonify()函数。借助jsonify()函数,我们仅需要传入数据或参数,它会对我们传入的参数进行序列化,转换成JSON字符串作为响应的主体,然后生成一个响应对象,并且设置正确的MIME类型。使用jsonify函数可以将前面的例子简化为这种形式:
from flask import jsonify @app.route('/foo') def foo(): return jsonify(name='Grey Li', gender='male')
jsonify()函数接收多种形式的参数。你既可以传入普通参数,也可以传入关键字参数。如果你想要更直观一点,也可以像使用dumps()方法一样传入字典、列表或元组,比如:
from flask import jsonify @app.route('/foo') def foo(): return jsonify({name: 'Grey Li', gender: 'male'})
上面两种形式的返回值是相同的,都会生成下面的JSON字符串:
'{"gender": "male", "name": "Grey Li"}'
另外,jsonify()函数默认生成200响应,你也可以通过附加状态码来自定义响应类型,比如:
@app.route('/foo') def foo(): return jsonify(message='Error! '), 500
提示
Flask在获取请求中的JSON数据上也有很方便的解决方案,具体可以参考我们在Request对象小节介绍的request.get_json()方法和request.json属性。
2.3.3 来一块Cookie
HTTP是无状态(stateless)协议。也就是说,在一次请求响应结束后,服务器不会留下任何关于对方状态的信息。但是对于某些Web程序来说,客户端的某些信息又必须被记住,比如用户的登录状态,这样才可以根据用户的状态来返回不同的响应。为了解决这类问题,就有了Cookie技术。Cookie技术通过在请求和响应报文中添加Cookie数据来保存客户端的状态信息。
附注
Cookie指Web服务器为了存储某些数据(比如用户信息)而保存在浏览器上的小型文本数据。浏览器会在一定时间内保存它,并在下一次向同一个服务器发送请求时附带这些数据。Cookie通常被用来进行用户会话管理(比如登录状态),保存用户的个性化信息(比如语言偏好,视频上次播放的位置,网站主题选项等)以及记录和收集用户浏览数据以用来分析用户行为等。
在Flask中,如果想要在响应中添加一个cookie,最方便的方法是使用Response类提供的set_cookie()方法。要使用这个方法,我们需要先使用make_response()方法手动生成一个响应对象,传入响应主体作为参数。这个响应对象默认实例化内置的Response类。表2-10是内置的Response类常用的属性和方法。
表2-10 Response类的常用属性和方法
附注
除了表2-10中列出的方法和属性外,Respone类同样拥有和Request类相同的get_json()方法、is_json()方法以及json属性。
set_cookie()方法支持多个参数来设置Cookie的选项,如表2-11所示。
表2-11 set_cookie()方法的参数
set_cookie视图用来设置cookie,它会将URL中的name变量的值设置到名为name的cookie里,如代码清单2-4所示。
代码清单2-4 http/app.py:设置cookie
from flask import Flask, make_response ... @app.route('/set/<name>') def set_cookie(name): response = make_response(redirect(url_for('hello'))) response.set_cookie('name', name) return response
在这个make_response()函数中,我们传入的是使用redirect()函数生成的重定向响应。set_cookie视图会在生成的响应报文首部中创建一个Set-Cookie字段,即“Set-Cookie: name=Grey;Path=/”。
现在我们查看浏览器中的Cookie,就会看到多了一块名为name的cookie,其值为我们设置的“Grey”,如图2-10所示。因为过期时间使用默认值,所以会在浏览会话结束时(关闭浏览器)过期。
图2-10 在浏览器中查看cookie
当浏览器保存了服务器端设置的Cookie后,浏览器再次发送到该服务器的请求会自动携带设置的Cookie信息,Cookie的内容存储在请求首部的Cookie字段中,整个交互过程由上到下如图2-11所示。
图2-11 Cookie设置示意图
在Flask中,Cookie可以通过请求对象的cookies属性读取。在修改后的hello视图中,如果没有从查询参数中获取到name的值,就从Cookie中寻找:
from flask import Flask, request @app.route('/') @app.route('/hello') def hello(): name = request.args.get('name') if name is None: name = request.cookies.get('name', 'Human') # 从Cookie中获取name值 return '<h1>Hello, %s</h1>' % name
注意
这个示例函数同样包含安全漏洞,后面会详细介绍。
这时服务器就可以根据Cookie的内容来获得客户端的状态信息,并根据状态返回不同的响应。如果你访问http://localhost:5000/set/Grey,那么就会将名为name的cookie设为Grey,重定向到/hello后,你会发现返回的内容变成了“Hello,Grey!”。如果你再次通过访问http://localhost:5000/set/<name>修改name cookie的值,那么重定向后的页面返回的内容也会随之改变。
2.3.4 session:安全的Cookie
Cookie在Web程序中发挥了很大的作用,其中最重要的功能是存储用户的认证信息。我们先来看看基于浏览器的用户认证是如何实现的。当我们使用浏览器登录某个社交网站时,会在登录表单中填写用户名和密码,单击登录按钮后,这会向服务器发送一个包含认证数据的请求。服务器接收请求后会查找对应的账户,然后验证密码是否匹配,如果匹配,就在返回的响应中设置一个cookie,比如,“login_user:greyli”。
响应被浏览器接收后,cookie会被保存在浏览器中。当用户再次向这个服务器发送请求时,根据请求附带的Cookie字段中的内容,服务器上的程序就可以判断用户的认证状态,并识别出用户。
但是这会带来一个问题,在浏览器中手动添加和修改Cookie是很容易的事,仅仅通过浏览器插件就可以实现。所以,如果直接把认证信息以明文的方式存储在Cookie里,那么恶意用户就可以通过伪造cookie的内容来获得对网站的权限,冒用别人的账户。为了避免这个问题,我们需要对敏感的Cookie内容进行加密。方便的是,Flask提供了session对象用来将Cookie数据加密储存。
附注
在编程中,session指用户会话(user session),又称为对话(dialogue),即服务器和客户端/浏览器之间或桌面程序和用户之间建立的交互活动。在Flask中,session对象用来加密Cookie。默认情况下,它会把数据存储在浏览器上一个名为session的cookie里。
1.设置程序密钥
session通过密钥对数据进行签名以加密数据,因此,我们得先设置一个密钥。这里的密钥就是一个具有一定复杂度和随机性的字符串,比如“Drmhze6EPcv0f N_81Bj-n A”。
程序的密钥可以通过Flask.secret_key属性或配置变量SECRET_KEY设置,比如:
app.secret_key = 'secret string'
更安全的做法是把密钥写进系统环境变量(在命令行中使用export或set命令),或是保存在.env文件中:
SECRET_KEY=secret string
然后在程序脚本中使用os模块提供的getenv()方法获取:
import os # ... app.secret_key = os.getenv('SECRET_KEY', 'secret string')
我们可以在getenv()方法中添加第二个参数,作为没有获取到对应环境变量时使用的默认值。
注意
这里的密钥只是示例。在生产环境中,为了安全考虑,你必须使用随机生成的密钥,在第14章我们会介绍如何生成随机密钥值。在本书中或相关示例程序中,为了方便会使用诸如secret string、dev key之类的占位文字。
2.模拟用户认证
下面我们会使用session模拟用户的认证功能。代码清单2-5是用来登入用户的login视图。
代码清单2-5 http/app.py:登入用户
from flask import redirect, session, url_for @app.route('/login') def login(): session['logged_in'] = True # 写入session return redirect(url_for('hello'))
这个登录视图只是简化的示例,在实际的登录中,我们需要在页面上提供登录表单,供用户填写账户和密码,然后在登录视图里验证账户和密码的有效性。session对象可以像字典一样操作,我们向session中添加一个logged-in cookie,将它的值设为True,表示用户已认证。
当我们使用session对象添加cookie时,数据会使用程序的密钥对其进行签名,加密后的数据存储在一块名为session的cookie里,如图2-12所示。
图2-12 session cookie
你可以在图2-12方框内的Content部分看到对应的加密处理后生成的session值。使用session对象存储的Cookie,用户可以看到其加密后的值,但无法修改它。因为session中的内容使用密钥进行签名,一旦数据被修改,签名的值也会变化。这样在读取时,就会验证失败,对应的session值也会随之失效。所以,除非用户知道密钥,否则无法对session cookie的值进行修改。
当支持用户登录后,我们就可以根据用户的认证状态分别显示不同的内容。在login视图的最后,我们将程序重定向到hello视图,下面是修改后的hello视图:
from flask import request, session @app.route('/') @app.route('/hello') def hello(): name = request.args.get('name') if name is None: name = request.cookies.get('name', 'Human') response = '<h1>Hello, %s! </h1>' % name # 根据用户认证状态返回不同的内容 if 'logged_in' in session: response += '[Authenticated]' else: response += '[Not Authenticated]' return response
session中的数据可以像字典一样通过键读取,或是使用get()方法。这里我们只是判断session中是否包含logged_in键,如果有则表示用户已经登录。通过判断用户的认证状态,我们在返回的响应中添加一行表示认证状态的信息:如果用户已经登录,显示[Authenticated];否则显示[Not authenticated]。
如果你访问http://localhost:5000/login,就会登入当前用户,重定向到http://localhost:5000/hello后你会发现加载后的页面显示一行“[Authenticated]”,表示当前用户已经通过认证,如图2-13所示。
图2-13 已认证的主页
程序中的某些资源仅提供给登入的用户,比如管理后台,这时我们就可以通过判断session是否存在logged-in键来判断用户是否认证,代码清单2-6是模拟管理后台的admin视图。
代码清单2-6 http/app.py:模拟管理后台
from flask import session, abort @app.route('/admin') def admin(): if 'logged_in' not in session: abort(403) return 'Welcome to admin page.'
通过判断logged_in是否在session中,我们可以实现:如果用户已经认证,会返回一行提示文字,否则会返回403错误响应。
登出用户的logout视图也非常简单,登出账户对应的实际操作其实就是把代表用户认证的logged-in cookie删除,这通过session对象的pop方法实现,如代码清单2-7所示。
代码清单2-7 http/app.py:登出用户
from flask import session @app.route('/logout') def logout(): if 'logged_in' in session: session.pop('logged_in') return redirect(url_for('hello'))
现在访问http://localhost:5000/logout则会登出用户,重定向后的/hello页面的认证状态信息会变为[Not authenticated],如图2-14所示。
图2-14 未认证的主页
提示
默认情况下,session cookie会在用户关闭浏览器时删除。通过将session.permanent属性设为True可以将session的有效期延长为Flask. permanent_session_lifetime属性值对应的datetime.timedelta对象,也可通过配置变量PERMANENT_SESSION_LIFETIME设置,默认为31天。
注意
尽管session对象会对Cookie进行签名并加密,但这种方式仅能够确保session的内容不会被篡改,加密后的数据借助工具仍然可以轻易读取(即使不知道密钥)。因此,绝对不能在session中存储敏感信息,比如用户密码。
2.4 Flask上下文
我们可以把编程中的上下文理解为当前环境(environment)的快照(snapshot)。如果把一个Flask程序比作一条可怜的生活在鱼缸里的鱼的话,那么它当然离不开身边的环境。
提示
这里的上下文和阅读文章时的上下文基本相同。如果在某篇文章里单独抽出一句话来看,我们可能会觉得摸不着头脑,只有联系上下文后我们才能正确理解文章。
Flask中有两种上下文,程序上下文(application context)和请求上下文(request context)。如果鱼想要存活,水是必不可少的元素。对于Flask程序来说,程序上下文就是我们的水。水里包含了各种浮游生物以及微生物,正如程序上下文中存储了程序运行所必须的信息;要想健康地活下去,鱼还离不开阳光。射进鱼缸的阳光就像是我们的程序接收的请求。当客户端发来请求时,请求上下文就登场了。请求上下文里包含了请求的各种信息,比如请求的URL,请求的HTTP方法等。
2.4.1 上下文全局变量
每一个视图函数都需要上下文信息,在前面我们学习过Flask将请求报文封装在request对象中。按照一般的思路,如果我们要在视图函数中使用它,就得把它作为参数传入视图函数,就像我们接收URL变量一样。但是这样一来就会导致大量的重复,而且增加了视图函数的复杂度。
在前面的示例中,我们并没有传递这个参数,而是直接从Flask导入一个全局的request对象,然后在视图函数里直接调用request的属性获取数据。你一定好奇,我们在全局导入时request只是一个普通的Python对象,为什么在处理请求时,视图函数里的request就会自动包含对应请求的数据?这是因为Flask会在每个请求产生后自动激活当前请求的上下文,激活请求上下文后,request被临时设为全局可访问。而当每个请求结束后,Flask就销毁对应的请求上下文。
我们在前面说request是全局对象,但这里的“全局”并不是实际意义上的全局。我们可以把这些变量理解为动态的全局变量。
在多线程服务器中,在同一时间可能会有多个请求在处理。假设有三个客户端同时向服务器发送请求,这时每个请求都有各自不同的请求报文,所以请求对象也必然是不同的。因此,请求对象只在各自的线程内是全局的。Flask通过本地线程(thread local)技术将请求对象在特定的线程和请求中全局可访问。具体内容和应用我们会在后面进行详细介绍。
为了方便获取这两种上下文环境中存储的信息,Flask提供了四个上下文全局变量,如表2-12所示。
表2-12 Flask中的上下文变量
提示
这四个变量都是代理对象(proxy),即指向真实对象的代理。一般情况下,我们不需要太关注其中的区别。在某些特定的情况下,如果你需要获取原始对象,可以对代理对象调用_get_current_object()方法获取被代理的真实对象。
我们在前面对session和request都了解得差不多了,这里简单介绍一下current_app和g。
你在这里也许会疑惑,既然有了程序实例app对象,为什么还需要current_app变量。在不同的视图函数中,request对象都表示和视图函数对应的请求,也就是当前请求(current request)。而程序也会有多个程序实例的情况,为了能获取对应的程序实例,而不是固定的某一个程序实例,我们就需要使用current_app变量,后面会详细介绍。
因为g存储在程序上下文中,而程序上下文会随着每一个请求的进入而激活,随着每一个请求的处理完毕而销毁,所以每次请求都会重设这个值。我们通常会使用它结合请求钩子来保存每个请求处理前所需要的全局变量,比如当前登入的用户对象,数据库连接等。在前面的示例中,我们在hello视图中从查询字符串获取name的值,如果每一个视图都需要这个值,那么就要在每个视图重复这行代码。借助g我们可以将这个操作移动到before_request处理函数中执行,然后保存到g的任意属性上:
from flask import g @app.before_request def get_name(): g.name = request.args.get('name')
设置这个函数后,在其他视图中可以直接使用g.name获取对应的值。另外,g也支持使用类似字典的get()、pop()以及setdefault()方法进行操作。
2.4.2 激活上下文
阳光柔和,鱼儿在水里欢快地游动,这一切都是上下文存在后的美好景象。如果没有上下文,我们的程序只能直挺挺地躺在鱼缸里。在下面这些情况下,Flask会自动帮我们激活程序上下文:
❑ 当我们使用flask run命令启动程序时。
❑ 使用旧的app.run()方法启动程序时。
❑ 执行使用@app.cli.command()装饰器注册的flask命令时。
❑ 使用flask shell命令启动Python Shell时。
当请求进入时,Flask会自动激活请求上下文,这时我们可以使用request和session变量。另外,当请求上下文被激活时,程序上下文也被自动激活。当请求处理完毕后,请求上下文和程序上下文也会自动销毁。也就是说,在请求处理时这两者拥有相同的生命周期。
结合Python的代码执行机制理解,这也就意味着,我们可以在视图函数中或在视图函数内调用的函数/方法中使用所有上下文全局变量。在使用flask shell命令打开的Python Shell中,或是自定义的flask命令函数中,我们可以使用current_app和g变量,也可以手动激活请求上下文来使用request和session。
如果我们在没有激活相关上下文时使用这些变量,Flask就会抛出Runtime Error异常:“Runtime Error: Working outside of application context.”或是“Runtime Error: Working outside of request context.”。
提示
同样依赖于上下文的还有url_for()、jsonify()等函数,所以你也只能在视图函数中使用它们。其中jsonify()函数内部调用中使用了current_app变量,而url_for()则需要依赖请求上下文才可以正常运行。
如果你需要在没有激活上下文的情况下使用这些变量,可以手动激活上下文。比如,下面是一个普通的Python shell,通过python命令打开。程序上下文对象使用app.app_context()获取,我们可以使用with语句执行上下文操作:
>>> from app import app >>> from flask import current_app >>> with app.app_context(): ... current_app.name 'app'
或是显式地使用push()方法推送(激活)上下文,在执行完相关操作时使用pop()方法销毁上下文:
>>> from app import app >>> from flask import current_app >>> app_ctx = app.app_context() >>> app_ctx.push() >>> current_app.name 'app' >>> app_ctx.pop()
而请求上下文可以通过test_request_context()方法临时创建:
>>> from app import app >>> from flask import request >>> with app.test_request_context('/hello'): ... request.method 'GET'
同样的,这里也可以使用push()和pop()方法显式地推送和销毁请求上下文。
2.4.3 上下文钩子
在前面我们学习了请求生命周期中可以使用的几种钩子,Flask也为上下文提供了一个teardown_appcontext钩子,使用它注册的回调函数会在程序上下文被销毁时调用,而且通常也会在请求上下文被销毁时调用。比如,你需要在每个请求处理结束后销毁数据库连接:
@app.teardown_appcontext def teardown_db(exception): ... db.close()
使用app.teardown_appcontext装饰器注册的回调函数需要接收异常对象作为参数,当请求被正常处理时这个参数值将是None,这个函数的返回值将被忽略。
上下文是Flask的重要话题,在这里我们也只是简单了解一下,在本书的第三部分,我们会详细了解上下文的实现原理。
2.5 HTTP进阶实践
在本书的第一部分,从本章开始,每一章的最后都会包含一个“进阶实践”部分,其中介绍的内容我们将会在第二部分的程序实例中使用到。在这一节,我们会接触到一些关于HTTP的进阶内容。
2.5.1 重定向回上一个页面
在前面的示例程序中,我们使用redirect()函数生成重定向响应。比如,在login视图中,登入用户后我们将用户重定向到/hello页面。在复杂的应用场景下,我们需要在用户访问某个URL后重定向到上一个页面。最常见的情况是,用户单击某个需要登录才能访问的链接,这时程序会重定向到登录页面,当用户登录后合理的行为是重定向到用户登录前浏览的页面,以便用户执行未完成的操作,而不是直接重定向到主页。在示例程序中,我们创建了两个视图函数foo和bar,分别显示一个Foo页面和一个Bar页面,如下所示:
@app.route('/foo') def foo(): return '<h1>Foo page</h1><a href="%s">Do something</a>' % url_for('do_ something') @app.route('/bar') def bar(): return '<h1>Bar page</h1><a href="%s">Do something </a>' % url_for('do_ something')
在这两个页面中,我们都添加了一个指向do_something视图的链接。这个do_something视图如下所示:
@app.route('/do_something') def do_something(): # do something return redirect(url_for('hello'))
我们希望这个视图在执行完相关操作后能够重定向回上一个页面,而不是固定的/hello页面。也就是说,如果在Foo页面上单击链接,我们希望被重定向回Foo页面;如果在Bar页面上单击链接,我们则希望返回到Bar页面。这一节我们会借助这个例子来介绍这一功能的实现。
1.获取上一个页面的URL
要重定向回上一个页面,最关键的是获取上一个页面的URL。上一个页面的URL一般可以通过两种方式获取:
(1)HTTP referer
HTTP referer(起源为referrer在HTTP规范中的错误拼写)是一个用来记录请求发源地址的HTTP首部字段(HTTP_REFERER),即访问来源。当用户在某个站点单击链接,浏览器向新链接所在的服务器发起请求,请求的数据中包含的HTTP_REFERER字段记录了用户所在的原站点URL。
这个值通常会用来追踪用户,比如记录用户进入程序的外部站点,以此来更有针对性地进行营销。在Flask中,referer的值可以通过请求对象的referrer属性获取,即request.referrer(正确拼写形式)。现在,do_something视图的返回值可以这样编写:
return redirect(request.referrer)
但是在很多种情况下,referrer字段会是空值,比如用户在浏览器的地址栏输入URL,或是用户出于保护隐私的考虑使用了防火墙软件或使用浏览器设置自动清除或修改了referrer字段。我们需要添加一个备选项:
return redirect(request.referrer or url_for('hello'))
(2)查询参数
除了自动从referrer获取,另一种更常见的方式是在URL中手动加入包含当前页面URL的查询参数,这个查询参数一般命名为next。比如,下面在foo和bar视图的返回值中的URL后添加next参数:
from flask import request @app.route('/foo') def foo(): return '<h1>Foo page</h1><a href="%s">Do something and redirect</a>' % url_ for('do_something', next=request.full_path) @app.route('/bar') def bar(): return '<h1>Bar page</h1><a href="%s">Do something and redirect</a>' % url_ for('do_something', next=request.full_path)
在程序内部只需要使用相对URL,所以这里使用request.full_path获取当前页面的完整路径。在do_something视图中,我们获取这个next值,然后重定向到对应的路径:
return redirect(request.args.get('next'))
用户在浏览器的地址栏直接访问时可以轻易地修改查询参数,为了避免next参数为空的情况,我们也要添加备选项,如果为空就重定向到hello视图:
return redirect(request.args.get('next', url_for('hello')))
为了覆盖更全面,我们可以将这两种方式搭配起来一起使用:首先获取next参数,如果为空就尝试获取referer,如果仍然为空,那么就重定向到默认的hello视图。因为在不同视图执行这部分操作的代码完全相同,我们可以创建一个通用的redirect_back()函数,如代码清单2-8所示。
代码清单2-8 http/app.py:重定向回上一个页面
def redirect_back(default='hello', **kwargs): for target in request.args.get('next'), request.referrer: if target: return redirect(target) return redirect(url_for(default, **kwargs))
通过设置默认值,我们可以在referer和next为空的情况下重定向到默认的视图。在do_something视图中使用这个函数的示例如下所示:
@app.route('/do_something_and_redirect') def do_something(): # do something return redirect_back()
2.对URL进行安全验证
虽然我们已经实现了重定向回上一个页面的功能,但安全问题不容小觑,鉴于referer和next容易被篡改的特性,如果我们不对这些值进行验证,则会形成开放重定向(Open Redirect)漏洞。
以URL中的next参数为例,next变量以查询字符串的方式写在URL里,因此任何人都可以发给某个用户一个包含next变量指向任何站点的链接。举个简单的例子,如果你访问下面的URL:
http://localhost:5000/do-something?next=http://helloflask.com
程序会被重定向到http://helloflask.com。也就是说,如果我们不验证next变量指向的URL地址是否属于我们的应用内,那么程序很容易就会被重定向到外部地址。你也许还不明白这其中会有什么危险,下面假设的情况也许会给你一个清晰的认识:
假设我们的应用是一个银行业务系统(下面简称网站A),某个攻击者模仿我们的网站外观做了一个几乎一模一样的网站(下面简称网站B)。接着,攻击者伪造了一封电子邮件,告诉用户网站A账户信息需要更新,然后向用户提供一个指向网站A登录页面的链接,但链接中包含一个重定向到网站B的next变量,比如:http://example A.com/login?next=http://malicious B.com。当用户在A网站登录后,如果A网站重定向到next对应的URL,那么就会导致重定向到攻击者编写的B网站。因为B网站完全模仿A网站的外观,攻击者就可以在重定向后的B网站诱导用户输入敏感信息,比如银行卡号及密码。
确保URL安全的关键就是判断URL是否属于程序内部,在代码清单2-9中,我们创建了一个URL验证函数is_safe_url(),用来验证next变量值是否属于程序内部URL。
代码清单2-9 http/app.py:验证URL安全性
from urlparse import urlparse, urljoin # Python3需要从urllib.parse导入 from flask import request def is_safe_url(target): ref_url = urlparse(request.host_url) test_url = urlparse(urljoin(request.host_url, target)) return test_url.scheme in ('http', 'https') and \ ref_url.netloc == test_url.netloc
注意
如果你使用Python3,那么这里需要从urllib.parse模块导入urlparse和urljoin函数。示例程序仓库中实际的代码做了兼容性处理。
这个函数接收目标URL作为参数,并通过request.host_url获取程序内的主机URL,然后使用urljoin()函数将目标URL转换为绝对URL。接着,分别使用urlparse模块提供的urlparse()函数解析两个URL,最后对目标URL的URL模式和主机地址进行验证,确保只有属于程序内部的URL才会被返回。在执行重定向回上一个页面的redirect_back()函数中,我们使用is_safe_url()验证next和referer的值:
def redirect_back(default='hello', **kwargs): for target in request.args.get('next'), request.referrer: if not target: continue if is_safe_url(target): return redirect(target) return redirect(url_for(default, **kwargs))
附注
关于开放重定向漏洞的更多信息可以访问https://www.owasp.org/index.php/Unvalidated_Redirects_and_Forwards_Cheat_Sheet了解。
2.5.2 使用AJAX技术发送异步请求
在传统的Web应用中,程序的操作都是基于请求响应循环来实现的。每当页面状态需要变动,或是需要更新数据时,都伴随着一个发向服务器的请求。当服务器返回响应时,整个页面会重载,并渲染新页面。
这种模式会带来一些问题。首先,频繁更新页面会牺牲性能,浪费服务器资源,同时降低用户体验。另外,对于一些操作性很强的程序来说,重载页面会显得很不合理。比如我们做了一个Web计算器程序,所有的按钮和显示屏幕都很逼真,但当我们单击“等于”按钮时,要等到页面重新加载后才在显示屏幕上看到结果,这显然会严重影响用户体验。我们这一节要学习的AJAX技术可以完美地解决这些问题。
1.认识AJAX
AJAX指异步Javascript和XML(Asynchronous Java Script And XML),它不是编程语言或通信协议,而是一系列技术的组合体。简单来说,AJAX基于XMLHttp Request(https://xhr.spec.whatwg.org/)让我们可以在不重载页面的情况下和服务器进行数据交换。加上Java Script和DOM(Document Object Model,文档对象模型),我们就可以在接收到响应数据后局部更新页面。而XML指的则是数据的交互格式,也可以是纯文本(Plain Text)、HTML或JSON。顺便说一句,XMLHttp Request不仅支持HTTP协议,还支持FILE和FTP协议。
提示
AJAX也常被拼作Ajax,但是为了和古希腊神话里的英雄Ajax区分开来,在本书中将使用全大写形式,即AJAX。
在Web程序中,很多加载数据的操作都可以在客户端使用AJAX实现。比如,当用户鼠标向下滚动到底部时在后台发送请求获取数据,然后插入文章;再比如,用户提交表单创建新的待办事项时,在后台将数据发送到服务器端,保存后将新的条目直接插入到页面上。
在这种模式下,我们可以在客户端实现大部分页面逻辑,而服务器端则主要负责处理数据。这样可以避免每次请求都渲染整个页面,这不仅增强了用户体验,也降低了服务器的负载。AJAX让Web程序也可以像桌面程序那样获得更流畅的反应和动态效果。总而言之,AJAX让Web程序更像是程序,而非一堆使用链接和按钮连接起来的网页资源。
以删除某个资源为例,在普通的程序中流程如下:
1)当“删除”按钮被单击时会发送一个请求,页面变空白,在接收到响应前无法进行其他操作。
2)服务器端接收请求,执行删除操作,返回包含整个页面的响应。
3)客户端接收到响应,重载整个页面。
使用AJAX技术时的流程如下:
1)当单击“删除”按钮时,客户端在后台发送一个异步请求,页面不变,在接收响应前可以进行其他操作。
2)服务器端接收请求后执行删除操作,返回提示消息或是无内容的204响应。
3)客户端接收到响应,使用Java Script更新页面,移除资源对应的页面元素。
2.使用j Query发送AJAX请求
j Query是流行的Java Script库,它包装了Java Script,让我们通过更简单的方式编写Java Script代码。对于AJAX,它提供了多个相关的方法,使用它可以很方便地实现AJAX操作。更重要的是,j Query处理了不同浏览器的AJAX兼容问题,我们只需要编写一套代码,就可以在所有主流的浏览器正常运行。
提示
使用j Query实现AJAX并不是必须的,你可以选择使用原生的XMLHttp Request、其他Java Script框架内置的AJAX接口,或是使用更新的Fetch API(https://fetch.spec.whatwg.org/)来发送异步请求。
在示例程序中,我们将使用全局j Query函数ajax()发送AJAX请求。ajax()函数是底层函数,有丰富的自定义配置,支持的主要参数如表2-13所示。
表2-13 ajax()函数支持的参数
附注
完整的可用配置参数列表可以在这里看到:http://api.jquery.com/j Query.ajax/#j Query-ajax-settings。
附注
j Query还提供了其他快捷方法(shorthand method):用于发送GET请求的get()方法和用于发送POST请求的post()方法,还有直接用于获取json数据的getjson()以及获取脚本的getscript()方法。这些方法都是基于ajax()方法实现的。在这里,为了便于理解,使用了底层的ajax方法。j Query中和AJAX相关的方法及其具体用法可以在这里看到:http://api.jquery.com/category/ajax/。
3.返回“局部数据”
对于处理AJAX请求的视图函数来说,我们不会返回完整的HTML响应,这时一般会返回局部数据,常见的三种类型如下所示:
1.纯文本或局部HTML模板
纯文本可以在Java Script用来直接替换页面中的文本值,而局部HTML则可以直接到插入页面中,比如返回评论列表:
@app.route('/comments/<int:post_id>') def get_comments(post_id): ... return render_template('comments.html')
2.JSON数据
JSON数据可以在Java Script中直接操作:
@app.route('/profile/<int:user_id>') def get_profile(user_id): ... return jsonify(username=username, bio=bio)
在j Query中的ajax()方法的success回调中,响应主体中的JSON字符串会被解析为JSON对象,我们可以直接获取并进行操作。
3.空值
有些时候,程序中的某些接收AJAX请求的视图并不需要返回数据给客户端,比如用来删除文章的视图。这时我们可以直接返回空值,并将状态码指定为204(表示无内容),比如:
@app.route('/post/delete/<int:post_id>', methods=['DELETE']) def delete_post(post_id): ... return '', 204
4.异步加载长文章示例
在示例程序的对应页面中,我们将显示一篇很长的虚拟文章,文章正文下方有一个“加载更多”按钮,当加载按钮被单击时,会发送一个AJAX请求获取文章的更多内容并直接动态插入到文章下方。用来显示虚拟文章的show_post视图如代码清单2-10所示。
代码清单2-10 http/app.py:显示虚拟文章
from jinja2.utils import generate_lorem_ipsum @app.route('/post') def show_post(): post_body = generate_lorem_ipsum(n=2) # 生成两段随机文本 return ''' <h1>A very long post</h1> <div class="body">%s</div> <button id="load">Load More</button> <script src="https://code.jquery.com/jquery-3.3.1.min.js"></script> <script type="text/javascript"> $(function() { $('#load').click(function() { $.ajax({ url: '/more', // 目标URL type: 'get', // 请求方法 success: function(data){ // 返回2XX响应后触发的回调函数 $('.body').append(data); // 将返回的响应插入到页面中 } }) }) }) </script>''' % post_body
文章的随机正文通过Jinja2提供的generate_lorem_ipsum()函数生成,n参数用来指定段落的数量,默认为5,它会返回由随机字符组成的虚拟文章。文章下面添加了一个“加载更多”按钮。按钮下面是两个<script></script>代码块,第一个script从CDN加载j Query资源。
在第二个script标签中,我们在代码的最外层创建了一个$(function(){ ... })函数,这个函数是常见的$(document).ready(function() { ... } )函数的简写形式。这个函数用来在页面DOM加载完毕后执行代码,类似传统Java Script中的window.onload方法,所以我们通常会将代码包装在这个函数中。美元符号是j Query的简写,我们通过它来调用j Query提供的多个方法,所以$.ajax()等同于j Query.ajax()。
在$(function() { ... })中,$('#load')被称为选择器,我们在括号中传入目标元素的id、class或是其他属性来定位到对应的元素,将其创建为j Query对象。我们传入了“加载更多”按钮的id值以定位到加载按钮。在这个选择器上,我们附加了.click(function() { ... } ),这会为加载按钮注册一个单击事件处理函数,当加载按钮被单击时就会执行单击事件回调函数。在这个回调函数中,我们使用$.ajax()方法发送一个AJAX请求到服务器,通过url将目标URL设为“/more”,通过type参数将请求的类型设为GET。当请求成功处理并返回2XX响应时(另外还包括304响应),会触发success回调函数。success回调函数接收的第一个参数为服务器端返回的响应主体,在这个回调函数中,我们在文章正文(通过$('.body')选择)底部使用append()方法插入返回的data数据。
提示
由于篇幅所限,我们不会深入介绍Java Script或j Query,你可以阅读其他书籍来学习更多内容。
处理/more的视图函数会返回随机文章正文,如下所示:
@app.route('/more') def load_post(): return generate_lorem_ipsum(n=1)
如果你启动了示例程序,那么访问http://localhost:5000/post可以看到文章页面,当你单击文章下的“Load More”按钮时,浏览器就会在后台发送一个GET请求到/more,这个视图返回的随机字符会被动态插入到文章下方。
附注
在出版业和设计业,lorem ipsum指一段常用的无意义的填充文字。以lorem ipsum开头的这段填充文本是抽取哲学著作《On the ends of good and evil》中的文段,并对单词进行删改调换而来。
2.5.3 HTTP服务器端推送
不论是传统的HTTP请求-响应式的通信模式,还是异步的AJAX式请求,服务器端始终处于被动的应答状态,只有在客户端发出请求的情况下,服务器端才会返回响应。这种通信模式被称为客户端拉取(client pull)。在这种模式下,用户只能通过刷新页面或主动单击加载按钮来拉取新数据。
然而,在某些场景下,我们需要的通信模式是服务器端的主动推送(server push)。比如,一个聊天室有很多个用户,当某个用户发送消息后,服务器接收到这个请求,然后把消息推送给聊天室的所有用户。类似这种关注实时性的情况还有很多,比如社交网站在导航栏实时显示新提醒和私信的数量,用户的在线状态更新,股价行情监控、显示商品库存信息、多人游戏、文档协作等。
实现服务器端推送的一系列技术被合称为HTTP Server Push(HTTP服务器端推送),目前常用的推送技术如表2-14所示。
表2-14 常用推送技术
按照列出的顺序来说,这几种方式对实时通信的实现越来越完善。当然,每种技术都有各自的优缺点,在具体的选择上,要根据面向的用户群以及程序自身的特点来分析选择。这些技术我们会在本书第二部分的程序实例中逐一介绍。
轮询(polling)这类使用AJAX技术模拟服务器端推送的方法实现起来比较简单,但通常会造成服务器资源上的浪费,增加服务器的负担,而且会让用户的设备耗费更多的电量(频繁地发起异步请求)。SSE效率更高,在浏览器的兼容性方面,除了Windows IE/Edge,SSE基本上支持所有主流浏览器,但浏览器通常会限制标签页的连接数量。
附注
Server-Sent Event的最新标准可以在WHATWG(https://html.spec.whatwg.org/multipage/server-sent-events.html)查看,浏览器的支持情况可以在Can I use...(https://caniuse.com/#feat=eventsource)查看。
除了这些推送技术,在HTML5的API中还包含了一个Web Socket协议,和HTTP不同,它是一种基于TCP协议的全双工通信协议(full-duplex communication protocol)。和前面介绍的服务器端推送技术相比,Web Socket实时性更强,而且可以实现双向通信(bidirectional communication)。另外,Web Socket的浏览器兼容性要强于SSE。
附注
Web Socket协议在RFC 6455(https://tools.ietf.org/html/rfc6455)中定义,浏览器的支持情况可以在Can I use...(https://caniuse.com/#feat=websockets)查看。
附注
如果你想进一步了解这几种推送技术的区别,Stack Overflow的这篇答案https://stackoverflow.com/a/12855533/5511849对这几种推送技术进行了对比,并提供了直观的图示。
2.5.4 Web安全防范
无论是简单的博客,还是大型的社交网站,Web安全都应该放在首位。Web安全问题涉及广泛,我们在这里介绍其中常见的几种攻击(attack)和其他常见的漏洞(vulnerability)。
对于Web程序的安全问题,一个首要的原则是:永远不要相信你的用户。大部分Web安全问题都是因为没有对用户输入的内容进行“消毒”造成的。
1.注入攻击
在OWASP(Open Web Application Security Project,开放式Web程序安全项目)发布的最危险的Web程序安全风险Top 10中,无论是最新的2017年的排名,2013年的排名还是最早的2010年,注入攻击(Injection)都位列第一。注入攻击包括系统命令(OS Command)注入、SQL(Structured Query Language,结构化查询语言)注入(SQL Injection)、No SQL注入、ORM(Object Relational Mapper,对象关系映射)注入等。我们这里重点介绍的是SQL注入。
附注
SQL是一种功能齐全的数据库语言,也是关系型数据库的通用操作语言。使用它可以对数据库中的数据进行修改、查询、删除等操作;ORM是用来操作数据库的工具,使用它可以在不手动编写SQL语句的情况下操作数据库。
附注
OWASP(https://www.owasp.org)是一个开源的、非盈利的国际性安全组织。在OWASP网站的Top 10页面中的Translation Efforts标签(https://www.owasp.org/index.php/Category:OWASP_Top_Ten_Project)下可以找到中文版本的Top 10报告。顺便说一句,我们在前面提及的开放重定向漏洞曾在2013 OWASP Top10中位列第10:Unvalidated Redirects and Forwards(未经验证的重定向或转发)。
(1)攻击原理
在编写SQL语句时,如果直接将用户传入的数据作为参数使用字符串拼接的方式插入到SQL查询中,那么攻击者可以通过注入其他语句来执行攻击操作,这些攻击操作包括可以通过SQL语句做的任何事:获取敏感数据、修改数据、删除数据库表……
(2)攻击示例
假设我们的程序是一个学生信息查询程序,其中的某个视图函数接收用户输入的密码,返回根据密码查询对应的数据。我们的数据库由一个db对象表示,SQL语句通过execute()方法执行:
@app.route('/students') def bobby_table(): password = request.args.get('password') cur = db.execute("SELECT * FROM students WHERE password='%s'; " % password) results = cur.fetchall() return results
注意
在实际应用中,敏感数据需要通过表单提交的POST请求接收,这里为了便于演示,我们通过查询参数接收。
我们通过查询字符串获取用户输入的查询参数,并且不经过任何处理就使用字符串格式化的方法拼接到SQL语句中。在这种情况下,如果攻击者输入的password参数值为“' or 1=1--”,即http://example.com/students?password=' or 1=1--,那么最终视图函数中被执行的SQL语句将变为:
SELECT * FROM students WHERE password='' or 1=1--; '
这时会把students表中的所有记录全部查询并返回,也就意味着所有的记录都被攻击者窃取了。更可怕的是,如果攻击者将password参数的值设为“'; drop table users;--”,那么查询语句就会变成:
SELECT * FROM students WHERE password=''; drop table students; --;
执行这个语句会把students表中的所有记录全部删除掉。
附注
在SQL中,“;”用来结束一行语句;“--”用来注释后面的语句,类似Python中的“#”。
(3)主要防范方法
1)使用ORM可以一定程度上避免SQL注入问题,我们将在第5章学习使用ORM。
2)验证输入类型。比如某个视图函数接收整型id来查询,那么就在URL规则中限制URL变量为整型。
3)参数化查询。在构造SQL语句时避免使用拼接字符串或字符串格式化(使用百分号或format()方法)的方式来构建SQL语句。而要使用各类接口库提供的参数化查询方法,以内置的sqlite3库为例:
db.execute('SELECT * FROM students WHERE password=? , password)
4)转义特殊字符,比如引号、分号和横线等。使用参数化查询时,各种接口库会为我们做转义工作。
附注
你可以访问OWASP的SQL注入页面(https://www.owasp.org/index.php/SQL_Injection)了解详细的攻击原理介绍的防范措施。
2.XSS攻击
XSS(Cross-Site Scripting,跨站脚本)攻击历史悠久,最远可以追溯到90年代,但至今仍然是危害范围非常广的攻击方式。在OWASP TOP 10中排名第7。
附注
Cross-Site Scripting的缩写本应是CSS,但是为了避免和Cascading Style Sheets的缩写产生冲突,所以将Cross(即交叉)使用交叉形状的X表示。
(1)攻击原理
XSS是注入攻击的一种,攻击者通过将代码注入被攻击者的网站中,用户一旦访问网页便会执行被注入的恶意脚本。XSS攻击主要分为反射型XSS攻击(Reflected XSS Attack)和存储型XSS攻击(Stored XSS Attack)两类。
(2)攻击示例
反射型XSS又称为非持久型XSS(Non-Persistent XSS)。当某个站点存在XSS漏洞时,这种攻击会通过URL注入攻击脚本,只有当用户访问这个URL时才会执行攻击脚本。我们在本章前面介绍查询字符串和cookie时引入的示例就包含反射型XSS漏洞,如下所示:
@app.route('/hello') def hello(): name = request.args.get('name') response = '<h1>Hello, %s! </h1>' % name
这个视图函数接收用户通过查询字符串传入的数据,未做任何处理就把它直接插入到返回的响应主体中,返回给客户端。如果某个用户输入了一段Java Script代码作为查询参数name的值,如下所示:
http://example.com/hello?name=<script>alert('Bingo! '); </script>
客户端接收的响应将变为下面的代码:
<h1>Hello, <script>alert('Bingo! '); </script>! </h1>
当客户端接收到响应后,浏览器解析这行代码就会打开一个弹窗,如图2-15所示。
图2-15 被注入代码后的响应
你觉得一个小弹窗不会造成什么危害?那你就完全错了,能够执行alert()函数就意味着通过这种方式可以执行任意Java Script代码。即攻击者通过Java Script几乎能够做任何事情:窃取用户的cookie和其他敏感数据,重定向到钓鱼网站,发送其他请求,执行诸如转账、发布广告信息、在社交网站关注某个用户等。
提示
即使不插入Java Script代码,通过HTML和CSS(CSS注入)也可以影响页面正常的输出,篡改页面样式,插入图片等。
如果网站A存在XSS漏洞,攻击者将包含攻击代码的链接发送给网站A的用户Foo,当Foo访问这个链接就会执行攻击代码,从而受到攻击。
存储型XSS也被称为持久型XSS(persistent XSS),这种类型的XSS攻击更常见,危害也更大。它和反射型XSS类似,不过会把攻击代码储存到数据库中,任何用户访问包含攻击代码的页面都会被殃及。比如,某个网站通过表单接收用户的留言,如果服务器接收数据后未经处理就存储到数据库中,那么用户可以在留言中插入任意Java Script代码。比如,攻击者在留言中加入一行重定向代码:
<script>window.location.href="http://attacker.com"; </script>
其他任意用户一旦访问留言板页面,就会执行其中的Java Script脚本。那么其他用户一旦访问这个页面就会被重定向到攻击者写入的站点。
(3)主要防范措施
a. HTML转义
防范XSS攻击最主要的方法是对用户输入的内容进行HTML转义,转义后可以确保用户输入的内容在浏览器中作为文本显示,而不是作为代码解析。
附注
这里的转义和Python中的概念相同,即消除代码执行时的歧义,也就是把变量标记的内容标记为文本,而不是HTML代码。具体来说,这会把变量中与HTML相关的符号转换为安全字符,以避免变量中包含影响页面输出的HTML标签或恶意的Java Script代码。
比如,我们可以使用Jinja2提供的escape()函数对用户传入的数据进行转义:
from jinja2 import escape @app.route('/hello') def hello(): name = request.args.get('name') response = '<h1>Hello, %s! </h1>' % escape(name)
附注
在Jinja2中,HTML转义相关的功能通过Flask的依赖包Markup Safe实现。
调用escape()并传入用户输入的数据,可以获得转义后的内容,前面的示例中,用户输入的Java Script代码将被转义为:
< script> alert(" Bingo! " )< /sript>
转义后,文本中的特殊字符(比如“>”和“<”)都将被转义为HTML实体(character entitiy),这行文本最终在浏览器中会被显示为文本形式的<script>alert('Bingo!')</script>,如图2-16所示。
图2-16 转义后的Java Script代码输出
附注
在Python中,如果你想在单引号标记的字符串中显示一个单引号,那么你需要在单引号前添加一个反斜线来转义它,也就是把它标记为普通文本,而不是作为特殊字符解释。在HTML中,也存在许多保留的特殊字符,比如大于小于号。如果你想以文本显示这些字符,也需要对其进行转义,即使用HTML字符实体表示这些字符。HTML实体就是一些用来表示保留符号的特殊文本,比如<表示小于号,"表示双引号。
提示
一般我们不会在视图函数中直接构造返回的HTML响应,而是会使用Jinja2来渲染包含变量的模板,这部分内容我们将在第3章学习。
b.验证用户输入
XSS攻击可以在任何用户可定制内容的地方进行,例如图片引用、自定义链接。仅仅转义HTML中的特殊字符并不能完全规避XSS攻击,因为在某些HTML属性中,使用普通的字符也可以插入Java Script代码。除了转义用户输入外,我们还需要对用户的输入数据进行类型验证。在所有接收用户输入的地方做好验证工作。在第4章学习表单时,我们会详细介绍表单数据的验证。
以某个程序的用户资料页面为例,我们来演示一下转义无法完全避免的XSS攻击。程序允许用户输入个人资料中的个人网站地址,通过下面的方式显示在资料页面中:
<a href="{{ url }}">Website</a>
其中{{ url }}部分表示会被替换为用户输入的url变量值。如果不对URL进行验证,那么用户就可以写入Java Script代码,比如“javascript:alert('Bingo!');”。因为这个值并不包含会被转义的<和>。最终页面上的链接代码会变为:
<a href="javascript:alert('Bingo! '); ">Website</a>
当用户单击这个链接时,就会执行被注入的攻击代码。
另外,程序还允许用户自己设置头像图片的URL。这个图片通过下面的方式显示:
<img src="{{ url }}">
类似的,{{ url }}部分表示会被替换为用户输入的url变量值。如果不对输入的URL进行验证,那么用户可以将url设为“123" onerror="alert('Bingo! ')”,最终的<img>标签就会变为:
<img src="123" onerror="alert('Bingo! ')">
在这里因为src中传入了一个错误的URL,浏览器便会执行onerror属性中设置的Java Script代码。
提示
如果你想允许部分HTML标签,比如<b>和<i>,可以使用HTML过滤工具对用户输入的数据进行过滤,仅保留少量允许使用的HTML标签,同时还要注意过滤HTML标签的属性,我们会在本书的第二部分详细了解。
附注
你可以访问OWASP的XSS页面(https://www.owasp.org/index.php/Cross-site_Scripting_(XSS))了解详细的攻击原理介绍和防范措施。
3.CSRF攻击
CSRF(Cross Site Request Forgery,跨站请求伪造)是一种近年来才逐渐被大众了解的网络攻击方式,又被称为One-Click Attack或Session Riding。在OWASP上一次(2013)的TOP 10 Web程序安全风险中,它位列第8。随着大部分程序的完善,各种框架都内置了对CSRF保护的支持,但目前仍有5%的程序受到威胁。
(1)攻击原理
CSRF攻击的大致方式如下:某用户登录了A网站,认证信息保存在cookie中。当用户访问攻击者创建的B网站时,攻击者通过在B网站发送一个伪造的请求提交到A网站服务器上,让A网站服务器误以为请求来自于自己的网站,于是执行相应的操作,该用户的信息便遭到了篡改。总结起来就是,攻击者利用用户在浏览器中保存的认证信息,向对应的站点发送伪造请求。在前面学习cookie时,我们介绍过用户认证通过保存在cookie中的数据实现。在发送请求时,只要浏览器中保存了对应的cookie,服务器端就会认为用户已经处于登录状态,而攻击者正是利用了这一机制。为了更便于理解,下面我们举一个实例。
(2)攻击示例
假设我们网站是一个社交网站(example.com),简称网站A;攻击者的网站可以是任意类型的网站,简称网站B。在我们的网站中,删除账户的操作通过GET请求执行,由使用下面的delete_account视图处理:
@app.route('/account/delete') def delete_account(): if not current_user.authenticated: abort(401) current_user.delete() return 'Deleted! '
当用户登录后,只要访问http://example.com/account/delete就会删除账户。那么在攻击者的网站上,只需要创建一个显示图片的img标签,其中的src属性加入删除账户的URL:
<img src="http://example.com/account/delete">
当用户访问B网站时,浏览器在解析网页时会自动向img标签的src属性中的地址发起请求。此时你在A网站的登录信息保存在cookie中,因此,仅仅是访问B网站的页面就会让你的账户被删除掉。
当然,现实中很少有网站会使用GET请求来执行包含数据更改的敏感操作,这里只是一个示例。现在,假设我们吸取了教训,改用POST请求提交删除账户的请求。尽管如此,攻击者只需要在B网站中内嵌一个隐藏表单,然后设置在页面加载后执行提交表单的Java Script函数,攻击仍然会在用户访问B网站时发起。
虽然CSRF攻击看起来非常可怕,但我们仍然可以采取一些措施来进行防御。下面我们来介绍防范CSRF攻击的两种主要方式。
(3)主要防范措施
a.正确使用HTTP方法
防范CSRF的基础就是正确使用HTTP方法。在前面我们介绍过HTTP中的常用方法。在普通的Web程序中,一般只会使用到GET和POST方法。而且,目前在HTML中仅支持GET和POST方法(借助AJAX则可以使用其他方法)。在使用HTTP方法时,通常应该遵循下面的原则:
❑ GET方法属于安全方法,不会改变资源状态,仅用于获取资源,因此又被称为幂等方法(idempotent method)。页面中所有可以通过链接发起的请求都属于GET请求。
❑ POST方法用于创建、修改和删除资源。在HTML中使用form标签创建表单并设置提交方法为POST,在提交时会创建POST请求。
附注
在GET请求中,查询参数用来传入过滤返回的资源,但是在某些特殊情况下,也可以通过查询参数传递少量非敏感信息。
虽然在实际开发中,通过在“删除”按钮中加入链接来删除资源非常方便,但安全问题应该作为编写代码时的第一考量,应该将这些按钮内嵌在使用了POST方法的form元素中。正确使用HTTP方法后,攻击者就无法通过GET请求来修改用户的数据,下面我们会介绍如何保护GET之外的请求。
b. CSRF令牌校验
当处理非GET请求时,要想避免CSRF攻击,关键在于判断请求是否来自自己的网站。在前面我们曾经介绍过使用HTTP referer获取请求来源,理论上说,通过referer可以判断源站点从而避免CSRF攻击,但因为referer很容易被修改和伪造,所以不能作为主要的防御措施。
除了在表单中加入验证码外,一般的做法是通过在客户端页面中加入伪随机数来防御CSRF攻击,这个伪随机数通常被称为CSRF令牌(token)。
附注
在计算机语境中,令牌(token)指用于标记、验证和传递信息的字符,通常是通过一定算法生成的伪随机数,我们在本书后面会频繁接触到这个词。
在HTML中,POST方法的请求通过表单创建。我们把在服务器端创建的伪随机数(CSRF令牌)添加到表单中的隐藏字段里和session变量(即签名cookie)中,当用户提交表单时,这个令牌会和表单数据一起提交。在服务器端处理POST请求时,我们会对表单中的令牌值进行验证,如果表单中的令牌值和session中的令牌值相同,那么就说明请求发自自己的网站。因为CSRF令牌在用户向包含表单的页面发起GET请求时创建,并且在一定时间内过期,一般情况下攻击者无法获取到这个令牌值,所以我们可以有效地区分出请求的来源是否安全。
附注
对于AJAX请求,我们可以在XMLHttp Request请求首部添加一个自定义字段X-CSRFToken来保存CSRF令牌。
我们通常会使用扩展实现CSRF令牌的创建和验证工作,比如Flask-Sea Surf(https://github.com/maxcountryman/flask-seasurf)、Flask-WTF内置的CSRFProtect(https://github.com/lepture/flask-wtf)等,在后面我们会详细介绍具体的实践内容。
注意
如果程序包含XSS漏洞,那么攻击者可以使用跨站脚本攻破可能使用的任何跨站请求伪造(CSRF)防御机制,比如使用Java Script窃取cookie内容,进而获取CSRF令牌。
附注
可以访问OWASP的CSRF页面(https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF))了解详细的攻击原理介绍的防范措施。
除了这几个攻击方式外,我们还有很多安全问题要注意。比如文件上传漏洞、敏感数据存储、用户认证(authentication)与权限管理等。这些内容我们将在后面的章节陆续介绍。
需要注意的是,虽然本书会介绍如何对常见的攻击和漏洞进行防御和避免,但仍然有许多其他的攻击和漏洞需要读者自己处理。另外,本书的示例程序(包括第一部分和第二部分)仅用于作为功能实现的示例,在安全方面并未按照实际运行的应用进行严格处理。比如,当单个用户出现频繁的登录失败时,应该采取添加验证码或暂时停止接收该用户的登录请求。请阅读OWASP或其他相关资料学习更多安全防御技巧。
附注
你应该列出一个程序安全项目检查清单,可以参考OWASP Top 10或是CWE(Common Weakness Enumeration,一般弱点列举)提供的Top 25(https://cwe.mitre.org/top25/)。确保你的程序所有的安全项目检查,也可以使用漏洞检查工具来,比如OWASP提供的Web Scarab(https://github.com/OWASP/OWASP-WebScarab)。
2.6 本章小结
HTTP是各种Web程序的基础,本章只是简要介绍了和Flask相关的部分,没有涉及HTTP底层的TCP/IP或DNS协议。建议你通过阅读相关书籍来了解完整的Web原理,这将有助于编写更完善和安全的Web程序。
在下一章,我们会学习使用Flask的模板引擎——Jinja2,通过学习运用模板和静态文件,我们可以让程序变得更加丰富和完善。