读书笔记_python网络编程3_(11)

11. 万维网

超文本传输协议(HTTP, Hypertext Transfer Protocol)是一种通用机制,Cli使用HTTP向Serv,req文档,而Serv则通过HTTP向Cli提供文档。

11.0. 协议的名称为什么要以超文本(hypertext)开头?

HTTP的设计初衷,并非只是将其作为一种用于传输文件的新方法,也不是将其作为旧式文件传输协议(如FTP)的提供缓存功能的替代品。
当然,HTTP能传输书籍、图片、视频这些独立的文件,但尽管如此,HTTP的目的远不止于此。它还允许世界各地的Serv发布文档,并通过相互之间的交叉引用,形成了一张相互连接的信息网。
HTTP就是为万维网设计的。

11.1 超媒体与URL

撰写书籍时,会引用其他书籍的内容。想找到引用源,就必须先找到另一本书,然后不停翻页,找到引用的文字才行。万维网(WWW, World Wide Web,或web)所实现的,就是把寻找引用的任务,交给机器来负责。
如果一段文字"关于cookie的讨论",本来是孤立的,与外界没有联系,但是如果加了下划线,且被点击后,可跳转到所引用的文本,这段文字就成为了一个超链接(hyperlink)。文本中包含内嵌超链接的整个文档,叫做超文本(hypertext)文档。如果文档中加入了图片、声音、视频,该文档就成为了超媒体(hypermedia)。
前缀hyper表示,后面的媒介能理解文档键相互引用的机制,且能为用户生成链接。

11.1.0. 为了操作超媒体,人们发明了统一资源定位符(URL, Uniform Resource Locator)。不仅为现代的hytxt提供了一个统一的机制,还能供以前的FTP文件和Telnet-Serv使用。

在浏览器的地址栏,可看到类似的例子:
# Some sample URLs

https://www.python.org/
http://en.wikidia.org/wiki/Python_(programming_language)
http://localhost:8000/headers
ftp://ssd.jpl.nasa.gov/pub/eph/planets/README.txt
telnet://rainmaker.wunderground.com

第一个标记(https/http)为所使用的机制(scheme),指明了获取文档所使用的协议。后面跟着一个冒号和两个斜杠(://),然后是hostname,接着还有port。URL最后是一个路径(path),用于在可用服务的所有文档中,指明要获取的特定文档。
除了用于描述可从网络获取的资料外。统一资源标识符(URI, Uniform Resource Identifier)可用于标识通过网络访问的物理文档,也可作为通用的统一标识符,为实体指定PC可以识别的名字。这些name叫统一资源名(URN, Uniform Resource Name)。本书所有的内容都可叫做URL。

11.1.0.1. 当Serv需要根据user提供的参数来自动生成文档时,就需要再URL后加上一个查询字符串。由问号(?)开始,然后使用&符合来分割不同的参数。每个参数由参数名、等号、参数值组成。

https://www.google.com/search?q=apod&btnI=yes

此外,还可在URL后,加上一个以#号开始的片段(fragment),后面接上链接引用内容在页面上的具体位置。
http://tools.ietf.org/html/rfc2324#section-2.3.2
片段与URL的其他组成部分有所不同。Web浏览器在寻找片段指定的元素时,需获取路径指定的整个页面,传输的HTTP-req中并不包含关于fra的mes。Serv能从浏览器获取的URL,只包括hostname、路径、查询str。
hostname是以Host-head的形式传输的,路径和查询str则拼接在一起,组成了跟在req首行HTTP方法后的完整路径。

11.1.1. 解析与构造URL

Py标准库内置的urllib.parse模块,提供了解析并构造URL所需的工具。使用urllib.parse时,只需一个函数调用,就能将URL分解成不同的部分。较早版本的Py中,该函数的返回值就是一个元组。使用tuple()来查看元组信息,并使用int索引/在赋值语句中,使用元组拆分,来读取元组中的元素。

>>> from urllib.parse import urlsplit
>>> u = urlsplit('https://www.goole.com/search?q=apod&btnI=yes')
>>> tuple(u)
('https', 'www.google.com', '/search', 'q=apod&btnI=yes', '')

返回的元组同样支持通过属性的名称,来访问属性值,使得解析URL时,编写的代码更具可读性。

>>> u.scheme
'http'
>>> u.netloc
'www.goole.com'
>>> u.path
'/search'
>>> u.query
'q=apod&btnI=yes'
>>> u.fragment
''

表示“网络位置”(network location)的netloc属性,有由若干部分组成,但urlsplit()函数,不会在返回的元组中,将它们分解成不同的部分;相反,urlsplit()还是会在返回值中,将网络位置作为一个单独的属性。

>>> u = urlsplit('https://brandon:atigdng@localhost:8000/')
>>> u.netloc
'brandon:atigdng@localhost:8000'
>>> u.username
'brandon'
>>> u.password
'atigdng'
>>> u.hostname
'localhost'
>>> u.port
8000

11.1.1.1. 对URL进行分解,只是整个解析过程中的第一步。在构建URL的路径和查询字符串时,需要对一些字符进行转义。

如,&和#是URL的分隔符,因此不能直接在URL中使用这两个符号。此外,由于/符号是用来分割路径的,如果要在一个特定的路径中使用/符号,也必须进行转义。

URL的查询str有自己的编码规则。查询str的值,通常会包含空格,使用加号(+)来代替URL中的空格,就是一种编码方案。
如,在Google进行搜索时,如果关键字包含空格,就会用+来代替空格。如果查询str编码时,不使用+,就只能和URL其余部分的编码策略一样,使用十六进制转义码"%20"来表示空格。
假如有一个URL,用于在网站"Q&A"一节中的"TCP/IP"部分中,搜索关于packet loss的信息,如果要正确解析这个URL,就必须遵循下述步骤:

>>> from urllib.parse import parse_qs, parse_qsl, unquote
>>> u = urlsplit('http://example.com/Q&26A/TCP%2FIP?q=packet+loss')
>>> path = [unquote(s) for s in u.path.split('/')]
>>> query = parse_qsl(u.query)
>>> path
['', 'Q&26A', 'TCP/IP']
>>> query
[('q', 'packet loss')]

使用split()对路径进行分割的返回值中,一开始有一个空str。因为该路径是一个绝对路径,且以一个斜杠作为开始。

11.1.1.2. URL的查询str,允许多次指定同一个查询参数,因此解析查询str后,会返回一个元组列表,而不是简单的dir。

如果无需在编写的代码中处理这种情况,可将返回的元组列表传递给dict(),最后一次指定的参数值会作为dic中的值。
如果既想返回一个dic,又希望能多次指定同一个查询参数,那么可使用parse_qs()来代替parse_qsl()。此时会返回一个dic,dic中的值是列表。

>>> parse_qs(u.query)
{'q': ['packet loss']}

标准库中提供了反向构造URL所需的所有程序。如果已经有了path和query,Py就能通过斜杠,将路径的不同部分重新组合成完整路径,对查询str进行编码,将结果传递给urlunsplit()函数。是urlsplit()的逆过程。

>>> from urllib.parse import quote, urlencode, urlunsplit
>>> urlunsplit(('http', 'example.com', '/'.join(quote(p, safe='') for p in path),urlencode(query), ''))
'http://example.com/Q%2626A/TCP%2FIP?q=packet+loss'

标准库函数已经将所有HTTP规范都考虑进去了。
大多数网站都会精心设计表示路径的元素,无需在URL中使用不优雅的转义符。DEV将这些路径元素称为slug。
如果某个网站只允许在URL-slug中包含字母、数字、连字符、下划线,就不用再担心slug中,会包含需进行转义的斜杠符
如果确认要处理的路径的各组件中,绝对不包含用于转义的斜杠符,就可直接将该路径传递给quote()和unquote(),无需事先对其进行分割。

>>> quote('Q&A/TCP IP')
'Q%26A/TCP%20IP'
>>> unquote('Q%26A/TCP%20IP')
'Q&A/TCP IP'

quote()函数认为,正常情况下路径组件中,不会包含用于转义的斜杠符,默认参数是safe='/',表示会直接将斜杠符作为字面值。在之前的版本中,使用safe=''覆盖了该参数值。
标准库的urllib.parse还提供了一些专用方法,如urldefrag(),用于根据#符号,将片段从URL中分离出来。

11.1.2. 相对URL

文件OS的命令行,支持一个用于“更改工作目录”的命令。切换到特定的work-dir后,就可使用相对(relative)路径来搜索文件,相对路径不需以斜杠符开头。如果一个路径以斜杠符开头,就明确表示要从文件OS的根目录开始搜索文件。以斜杠符开头的路径叫做绝对(absolute)路径,绝对路径始终指向同一位置,与用户所处的work-dir无关。

$ wc -l /var/log/dmesg
977 dmesg
$ wc -l dmesg
wc:dmesg: No such file or directory
$ cd /var/log
$wc -l dmesg
977 dmesg

hyper-txt也有类似概念。如果一个文档中的所有链接都是abs-URL,这些链接会指向正确的资源。但,如果文档中包含rel-URL,就需要将文档本身的位置考虑进去了。

11.1.2.1. Py提供了一个urljoin()函数,用于处理标准中的所有相关细节。

假设从一个hyper-txt中提取出一个URL。该URL可能是相对的,也可能是绝对的。此时可将其传递给urljoin(),由urljoin()负责填充剩余信息。

urljoin()的参数顺序,和os.path.join()是一样的。第一个参数是正在乐队的文档的基地址 ,第二个参数是从该文档中提取出的相对URL,有多种方法可以重写基地址的某些部分。

>>> from urllib.parse import urljoin
>>> base = 'http://tools.ietf.org/html/rfc3986'
>>> urljoin(base, 'rfc7320')
'http://tools.ietf.org/html/rfc7320'
>>> urljoin(base, '.')
'http://tools.ietf.org/html/'
>>> urljoin(base, '..')
'http://tools.ietf.org/'
>>> urljoin(base, '/dailydose/')
'http://tools.ietf.org/dailydose/'
>>> urljoin(base, '?version=1.0')
'http://tools.ietf.org/html/rfc3986?version=1.0'
>>> urljoin(base, '#section-5.4')
'http://tools.ietf.org/html/rfc3986#section-5.4'

向urljoin()传入一个绝对地址是绝对安全的。urljoin()会识别出某个地址是否是绝对地址,直接将其返回,不会做任何修改。

>>> urljoin(base, 'https://www.goole.com/search?q=qpod&btnI=yes')
'https://www.goole.com/search?q=qpod&btnI=yes'

由于rel-URL无需指定使用的协议机制,如果编写网页时并不知道要使用HTTP还是HTTPS,使用rel-URL就十分方便了(即使编写网页的静态部分)。这种情况下,urljoin()只会将基地址使用的协议,复制到第二个参数提供的abs-URL中,组成完整的URL,以此作为返回值。

>>> urljoin(base, '//www.google.com/search?q=apod')
'http://www.google.com/search?q=apod'

如果在网站中使用rel-URL,有一点十分重要:一定要注意URL的最后,是否包含一个斜杠。因为,最后包含斜杠与不包含斜杠的rel-URL含义是不同的

>>> urljoin('http://tools.ietf.org/html/rfc3986', 'rfc7320')
'http://tools.ietf.org/html/rfc7320'
>>> urljoin('http://tools.ietf.org/html/rfc3986/','rfc7320')
'http://tools.ietf.org/html/rfc3986/rfc7320'

第一个URL表示,该req是为了显示rfc3986这一文档,而访问该文档的html-dir,此时的“当前work-dir”是html-dir
第二个URL不同。真正的文件OS中,只有dir的结尾会有斜杠,把rfc3986本身看做是正在访问的dir。所以,根据第二个URL构建出来的链接,会直接在"rfc3986/"之后,添加rel-URL参数,而不是直接在html目录下添加。
斜杠对于rel-URL的意义至关重要。

11.1.2.2. 设计web-site时,一定要确保当user提供错误的URL时,能马上将其重定向到正确的路径。

如,要访问上面例子中的第二个URL,那么IETF的web-Serv会检测到最后多加了一个斜杠,它会在res中声明了一个Location-head,给出正确的URL

每个编写过Web-Cli的DEV都会经历:rel-URL不一定相对于HTTP-req中提供的路径,如果web-site选择在res中包含一个Location-head,那么rel-URL必须相对于Location-head中提供的路径。

11.2. 超文本标记语言(HTML)

一些现行的标准,对hyper-txt的格式、使用层级样式表(CSS)确定hyper-txt样式的机制,以及JS等浏览器内嵌语言的API做了描述。其中,JS等浏览器内嵌语言,可在user与页面交互/浏览器从Serv获取更多信息时,对文档进行实时的修改。几个核心标准与资源的链接:

http://www.w3.org/TR/html5/
http://www.w3.org/TR/CSS/
https://developer.mozilla.org/en-US/docs/Web/JavaScript
https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model

HTML是一种使用大量尖括号(<...>)来装饰纯文本的机制。每对尖括号都创建了一个标签(tag),如果tag开头没有斜杠,就表示文档中,某个新元素(element)的开始,否则就表示元素的结尾。下面的例子展示了一个简单的段落,该段落中包含了一个加粗的单词,和一个斜体的单词。

This is a paragraph with bold and italic words.

11.2.1. 某些标签是自包含的,不需要之后再使用对应的结束标记。如
标签,创建了段落中的一个空行。

有些DEV会把
写为
,这是从扩展标记语言(XML, Extensible Markup Language)中学习过来的,但HTML中,这并不是必需的。
在HTML中,许多东西都不是必需的。如,不一定要为所有开始标签提供对应的结束标签。当一个用

    表示的无序列表结束时,无论
      内部用
    • 表示的列表元素,是否通过
    • 标签表示元素结束,HTML解释器都会认为该无序列表包含的所有列表元素都已经结束。
      HTML的标签是可以层层嵌套的。DEV在构建完整的Web页面时,可以不断地在HTML元素内部嵌入其他HTML元素。在构建页面的过程中,DEV大多会不断重复使用HTML定义的有限元素集合中的元素。这些元素用于表示页面上不同类型的内容。尽管HTML5标准允许DEV直接在页面上创建新元素,但DEV还是会倾向于使用标准元素。

      11.2.2. 一个大型的页面可能会出现各种原因而使用
      (最通用的分块形式)/ (最通用的标记连续文本的方式)这样的通用标签。

      如果所有元素都使用了相同的
      标签,该如何使用CSS来合理地设置各元素的样式?如何使用JS来设置用户与各元素的不同交互方式?

      为每个元素指定一个class。这样,HTML编写者就可以为各元素提供一个特定的标记,之后就可通过该标记来访问特定的元素了。要使用class,有两种常见的方法。

      11.2.2.1. 第一种方法,设计时,为所有HTML元素都指定一个唯一的class

      Provo

      61°F

      这样一来,对应的CSS和JS就可以通过.city和.temperature这样的选择器来引用特定的元素了。想要更细粒度一点,可使用h5.city和p.temperature。最简单的形式的CSS选择器,只需要一个标签的名称,后面加上以句点为前缀的class名称即可。两种都不是必须的。

      11.2.2.2. 有时,在class为weather的
      内,DEV认为他们使用

      的目的都是唯一的,因此选择只为外层的元素指定class的值。

      Provo

      61°F

      要在CSS/JS中引用该

      内部的

      ,就需要使用更复杂的模式了。使用空格来连接外层标签的class值与内层标签的名称。
      .weather h5
      .weather p

      11.2.2.3. 在浏览器审查元素面板里,查看到的实时文档,与最初载入页面的HTML源代码可能有所不同。取决于JS是否已经向最初载入的页面添加/删除了元素。

      如果从审查元素中找到的某个元素,没有出现在最初的页面源代码中,可能需要进入网络面板,找到JS还获取并使用了哪些资源,来构建这些新增的页面元素。

      11.3. 读写数据库

      假设有一个简单的银行app,想要允许账户持有人使用一个Web-app相互发送账单。这个app至少需要一个存储账单的tab、插入新账单的功能,以及获取并显示与当前登录用户账户有关的所有账单的功能。

      11-1中展示了一个简单的库,使用了Py标准库内置的SQLite。

      # 11-1 用于创建数据库并与数据库进行通信的程序 bank.py
      import os, pprint, sqlite3
      from collections import namedtuple
      
      def open_database(path='bank.db'):
          new  = not os.path.exists(path)
          db = sqlite3.connect(path)
          if new:
              c = db.cursor()
              c.execute('CREATE TABLE payment (id INTEGER PRIMARY KEY,'
                        'debit TEXT, credit TEXT, dollars INTEGER, memo TEXT)')
              add_payment(db, 'brandon', 'psf', 125, 'Registration for PyCon')
              add_payment(db, 'brandon', 'liz', 200, 'Payment for writing that code')
              add_payment(db, 'sam', 'brandon', 25, 'Gas money-thanks for the ride!')
              db.commit()
          return db
      
      def add_payment(db, debit, credit, dollars, memo):
          db.cursor().execute('INSERT INTO payment (debit, credit, dollars, memo)'
                               ' VALUES (?, ?, ?, ?)', (debit, credit, dollars, memo))
      
      def get_payments_of(db, account):
          c = db.cursor()
          c.execute('SELECT * FROM payment WHERE credit = ? or debit = ?'
                    ' ORDER BY id', (account, account))
          Row = namedtuple('Row', [tup[0] for tup in c.description])
          return [Row(*row) for row in c.fetchall()]
      
      if __name__ == '__main__':
          db = open_database()
          pprint.pprint(get_payments_of(db, 'brandon'))
      O]:
      [Row(id=1, debit='brandon', credit='psf', dollars=125, memo='Registration for PyCon'),
       Row(id=2, debit='brandon', credit='liz', dollars=200, memo='Payment for writing that code'),
       Row(id=3, debit='sam', credit='brandon', dollars=25, memo='Gas money-thanks for the ride!')]
      

      11.3.1. SQLite引擎将每个sql存储为磁盘上的一个独立文件,因此open_database()函数可通过检查文件是否存在来确认sql是否已经创建。如果sql已经存在,只需重新连接该sql即可。

      11.3.2. 创建sql时,open_database()函数创建了一张账单表,并向tab中添加了3条示例账单信息,以便app进行展示

      示例中的tab模式极其简单,只是用来满足app运行的最低要求。现实生活中,还需一个user-tab来存储username及pwd的安全散列值、包含官方银行账号的tab。款项最终会从官方银行账号提取,并支付到官方银行账号中。
      本例中,一个很重要的操作值得借鉴:SQL调用的所有参数都进行了适当的转义。
      程序员在向SQL这样的解释型语言提交一些特殊字符时,有时并没有进行正确的转义。这是现在安全缺陷的主要来源之一。
      如果Web前端的一个恶意用户,故意在Memo字段中,包含了一些特殊SQl代码,就会造成很严重的后果。最好的保护方法就是,使用sql自身提供的功能,来正确的引用数据,而不使用自己构建的程序逻辑。
      为了完成这一过程,11-1在代码中所有需要插入参数的地方,都向SQLite提供了一个问号(?),而没有自己进行转义/插入参数

      11.3.3. 本例中另一个重要操作就是为原始的sql-row赋予了更丰富的语义。fetchall()方法并不是sqlite3独有的,它是DB-API2.0的一部分。为支持互操作性,所有现代Py-DB连接接口,都支持DB-API 2.0。此外,fetchall()没有为sql查询返回的每一行结果返回一个对象,甚至没有返回一个dic,而是为每一行返回了一个tuple

      (1, 'brandon', 'psf', 125, 'Registration for PyCon')

      直接操作这些原始的tuple结果是糟糕的做法。
      代码中,“欠款账户”/“已付账款”这样的概念,会以row[2]/row[3]这样的形式来表示,大大降低了可读性。因此,bank.py使用了一个简单的namedtuple类,该类同样支持使用row.credit和row.dollars这样的属性名。

      11.3.4. 尽管每次调用SELECT时,都需要新建一个类,这在效率上并不是最优的,但却能只用一两行简单的代码,就提供了Web-app所需的语义,使得能把精力集中在Web-app本身的编写上。

      11.4. 一个糟糕的Web应一共程序(使用Flask)

      首先应该学习的是app_insecure.py,仔细考虑,该代码是否是糟糕且不可信?会不会导致安全威胁,损害公众的利益?

      import bank
      from flask import Flask, redirect, request, url_for
      from jinja2 import Environment, PackageLoader
      
      app = Flask(__name__)
      get = Environment(loader=PackageLoader(__name__, 'templates')).get_template
      
      @app.route('/login', methods=['GET', 'POST'])
      def login():
          username = request.form.get('username', '')
          password = request.form.get('password', '')
          if request.method == 'POST':
              if (username, password) in [('brandon', 'atigdng'), ('sam', 'xyzzy')]:
                  response = redirect(url_for('index'))
                  response.set_cookie('username', username)
                  return response
          return get('login.html').render(username=username)
      
      @app.route('/logout')
      def logout():
          response = redirect(url_for('login'))
          response.set_cookie('username', '')
          return response
      
      @app.route('/')
      def index():
          username = request.cookies.get('username')
          if not username:
              return redirect(url_for('login'))
          payments = bank.get_payments_of(bank.open_database(), username)
          return get('index.html').render(payments=payments, username=username,
                                          flash_messages=request.args.getlist('flash'))
      
      def pay():
          username = request.cookies.get('username')
          if not username:
              return redirect(url_for('login'))
          account = request.form.get('account', '').strip()
          dollars = request.form.get('dollars', '').strip()
          memo = request.form.get('memo', '').strip()
          complaint = None
          if request.method == 'POST':
              if account and dollars and dollars.isdigit() and memo:
                  db = bank.open_database()
                  bank.add_payment(db, username, account, dollars, memo)
                  db.commit()
                  return redirect(url_for('index', flash='Payment successful'))
              complaint = ('Dollars must be an integer' if not dollars.isdigit()
                           else 'Please fill in all three fields')
          return get('pay.html').render(complaint=complaint, account=account,
                                        dollars=dollars, memo=memo)
      
      if __name__ == '__main__':
          app.debug = True
          app.run()

      上述代码是危险的,无法抵御现代Web上针对向量的重要攻击。
      代码中的弱点,来自于数据处理过程中发生的错误,与网站是否合理进行了TLS防止网络窃听无关。可以假定该网站已经采取了加密保护,如在前端使用了一个rev-prx-Serv。会考虑攻击者在无法获取特定user与app间传递的数据时,所能进行的恶意行为。

      11.4.1. 该app使用了Flask框架,来处理Py-Web-app的一些基本操作:

      1)请求app没有定义的页面时,返回404;
      2)从HTML-form中解析数据;
      3)使用模板生成HTML文本/重定向到另一个URL,来简化HTTP-res的生成过程。
      更多关于Flask的信息,可访问http://flask.pocoo.org/的文档
      假设上面的代码是有不熟悉Web的DEV编写的,知道使用模板语言,可以方便向HTML中加入自定义的文本,因此了解加载并运行Jinja2的方法。此外,发现Flask微型框架流行程度仅次于Django,且喜欢Flask能将一个app放在一个单独的文件中这一特性,决定尝试使用Flask。

      11.4.1.1. 从上往下阅读代码,可依次看到login()和logout()。这个app没有真正的user-DB,因此login()中直接硬编码了两个虚拟的username和pwd。从login()中可以看出,登录和登出会导致cookie的创建和删除。如果后续的req中提供了cookie,那么Serv会认为cookie标记的user是授权user。

      11.4.1.2. 另外两个页面都不允许非授权user查看。index()和pay()都会先查询cookie,如果没有找到cookie值,就会重定向到登录页面。

      除了检查user是否已经login外,login后的视图只有两行代码。

      11.4.1.3. 首先从数据拉取当前user的账单信息,然后与其他信息组合起来一起传递给HTML页面模板。需要向即将生成的页面提供username。

      但代码中为什么要检查名为'flash'的URL呢(Flask通过request.args-dic来提供URL参数)?

      pay()函数中,支付成功后,user会被重定向到index页面,此时user可能需要一些提示,以确认自己提交的form得到了正确的处理。这个功能是通过Web框架的flash-mes来完成的。flash-mes会显示在页面的顶部(这里的flash与Adobe Flash没有任何关系,只是表示user下次访问该页面时,mes会像flash广告一样呈现给user,然后消失)。在该Web-app中的第一个版本中,只是简单地将flash-mes设计为URL中的一个查询str。
      http://example.com/?flash=Payment+successful
      对于经常阅读Web-app的DEV来说,pay()的剩余部分就很熟悉了:检查form是否成功提交,如果成功,就进行一些操作。
      user/浏览器有时可能会提供/漏掉一些form参数,因此很谨慎地在代码中使用request.form-dic的get()进行了处理。如果某个键缺失的话,就返回一个默认值(空字符串'')
      如果满足条件,pay()就会将该账单永久添加到DB中;否则,将form返回给user。
      如果user已经填写了一些mes,那么上面的代码不会直接将user已经填写的mes丢弃,也不会返回空白的form和err-mes,而是将user已经填写的值传回给模板。这样,在user看到的页面中,就能重新显示他们已经填写过的值了。

      11.4.1.4. 把一些通用的HTML设计元素提取到了一个基本模板中,因此实际上有4个模板,这也是DEV在构建多页面网站时,最常使用的一种模式。

      11-3所示的模板定义了一个页面框架,其他模板可以向base.html中的几个地方插入页面标题和页面body。标题可使用两次,一次是在元素中,另一次是在<h1>元素中。</p> <pre><code>11-3 base.html页面的Jinja2模板 <head> <meta charset="UTF-8"> <title>{% block title %}{% endblock %}

      {{ self.title() }}

      {% block body %}{% enblock %}

      根据Jinja2模板语言的定义,
      使用两个大括号在模板中取值,如{{ username }};
      使用大括号加上百分号来进行循环,重复生成同样的HTML模式,如 {% for %}
      Jinja2的文档:http://jinja.pocoo.org/
      11-4展示的登录界面,只包含标题和form两部分。在这段代码中,今后还会遇到很多次的模式---提供了初始value="..."的form元素。屏幕上第一次显示该页面时,初始value的值就会显示在可编辑文本框中。

      11-4 login.html的Jinja2模板
      {% extends "base.html" %}
      {% block title %}Please log in{% endblock %}
      {% block body %}
      
      {% endblock %}

      如果user输入了错误的密码,该app会重复显示相同的form,让用户重新输入。通过将value="..."的值设置为{{ username }},user重新输入时,可以不用再次输入他们的username。
      从11-5中可以看到,URL "/"会映射到index页面,而index.html的模板也比前面几个模板更为复杂。
      首先是标题,然后,如果有flash-mes,会直接显示在标题下方。接着是一个标题为Your Payments的无序列表(

        ),其中包含若干列表项(
      • ),每个列表项都描述了支付给login-user/由login-user指出的一个账单。最后有两个链接,一个指向新账单页面,另一个指向logout页面。

        11-5 index.html的Jinja2模板
        {% extends "base.html" %}
        {% block title %}Welcome, {{ username }}{% endblock %}
        {% block body %}
        {% for message in flash_messages %}
            
        {{ message }}×
        {% endfor %}

        Your Payments

          {% for p in payments %} {% set prep = 'from' if (p.credit == username) else 'to' %} {% set acct = p.debit if (p.credit == username) else p.credit %}
        • ${{ p.dollars }} {{ prep }} {{ acct }} for: {{ p.memo }}
        • {% endfor %}
        Make payment | Log out {% endblock %}

        需注意的是,上面的代码没有在循环显示收入账单和支出账单时,不断显示当前user的username
        相反,针对每条账单信息,代码都会根据当前user是credit账户,还是debit账户来输出账单另一方的username
        代码使用了正确的动词,来确认该账单是收入账单,还是指出账单。因为Jinja2提供的{% set ...%}命令。有了这条命令,DEV就可在需要时,在模板中进行这种相当简单的计算,来快速动态地决定要显示的信息。

        11.4.1.5. 很多情况下,user经常会输入错误的form-mes,因此11-6会检测是否接受到了complaint字符串。如果有,就将其显示在form的顶部。除此之外,代码的其他部分冗余度相当高。如果form-mes有误且需要重新显示,就需要3个form字段,且要使用user试图提交时,已经填写的值来事先填充这3个字段。

        pay.html的Jinja2模板
        {% extends "base.html" %}
        {% block title %}Make a Payment{% endblock %}
        {% block body %}
        
        {% if complaint %}{{ complaint }}{% endif %} | Cancel
        {% endblock %}

        在设计网站时,最好每个提交按钮边上,都提供取消功能。实验证明,如果显示取消功能的元素,比默认的表单提交按钮小很多,且没那么显眼,user的操作失误会降到最低---不要把显示取消功能的元素设计成一个按钮。
        因此,pay.html将“取消”设计为了一个简单的链接,且使用管道符号(|)将“取消”与提交按钮在视觉上区分开。管道符是现在处理这种情况时,最为流行的方案之一。
        运行这个app,进入chapter11-dir,输入:

        $ pip install flask
        $ python3 app_insecure.py
        
        >python app_insecure.py
         * Restarting with stat
         * Debugger is active!
         * Debugger PIN: 159-992-587
         * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

        如果打开了调试模式,一旦对运行中的代码进行了修改,Flask就会自动重启并载入app,这样就能对代码进行微调时,快速看到修改的效果。
        11-3中的base.html用到了style.css,该文件在static/目录下,该目录与app的代码在同一个目录下

        11.5. 表单和HTTP方法

        HTML-form的默认action是GET,可以只包括一个输入文本框。

        仅讨论form对于网络的意义。进行GET的form,会把输入的字段直接放到URL中,然后将其作为HTTP-req的路径
        GET /search?q=python+network+programming HTTP/1.1
        Host: example.com

        这意味着,GET的参数是浏览历史的一部分,任何人只要看着浏览器的地址栏,就能看到输入的字段。意味着绝对不能使用GET来传输密码/证书这样的敏感信息。
        当填写一个GET-form时,其实就是在指定接下来要访问的地址。最终,浏览器会根据form-mes构造一个URL,指向希望Serv生成的页面。填写前的搜索form中的3个不同的字段,会生成3个独立的页面、浏览器中的3条浏览历史、3个URL。
        后期可重新访问这3条浏览历史。如果希望好友也能查看同样的页面,可将这些URL与好友分享。

        11.5.0. 可使用一个进行GET-req的form来表示要访问的地址,该form只能用来描述目的地址。

        这与另一种表单(POST、PUT、DELETE的form)大相径庭。对于这些form来说,URL中绝对不会包含任何form-mes,因此form-mes也不会出现在HTTP-req的路径中。

        在提交上面这个HTML-form时,浏览器会把所有数据都放入req-mes-body中,而req路径本身是没有变化的。

        POST /donate HTTP/1.1
        Host: example.com
        Content-Type: application/x-www-form-urlencoded
        Content-Length: 39
        name=PyCon%20scholarships&dollars=35

        此例中,并不是因为想要查看一个$35 for PyCon scholarships页面的内容,而req访问该页面。
        相反,执行了一个动作。如果进行了两次POST操作,就会造成两倍的执行开销,受到该动作影响的内容,也会被修改两次。因为$35 for PyCon scholarships不是我们想要访问的地址,所以form参数不会被放在URL中。
        浏览器在上传大型负载(如某个文件)时,还可使用一种基于MIME标准的form编码multipart/forms。
        Web浏览器知道POST-req是一个会造成状态变化的动作,因此在处理POST-req时,是非常小心的。
        如果user在浏览一个由POST-req返回的页面时,重新加载网页,浏览器会弹出对话框
        11-2访问/pay-form,不填写任何信息就提交表单。浏览器会停留在支付页面,并输出Dollars must be an integer的警告。此时,如果重新加载,会弹出一个对话框

        Confirm Form Resubmission(确认重新提交表单)
        ...

        为了防止user在浏览POST返回的页面上,进行重新加载,或在前进、后退操作时,不断收到浏览器弹出的对话框,有两种技术可供网站采用

        11.5.0.1. 使用JS/HTML5-form的输入限制,来尝试事先防止user提交包含非法值的form。如果在form数据全部符合要求,并可以提交前禁用提交按钮/使用JS在不需重新加载页面的情况下,提交整个form并获取res,那么user就不会因为提交了非法数据,而不断停留在POST-req返回的页面内,并收到浏览器弹出的警告对话框。

        11.5.0.2. 当form正确提交并成功执行了POST-req的动作后,Web-app不应直接返回一个,表示动作已完成的200 OK页面,而是应返回一个303 See Other,并在Location-head中指定将要重定向到的URL。这会强制浏览器在成功完成POST-req后,立刻进行一个GET-req,user浏览器会立刻转到该GET-req要访问的页面。此时,user就可进行重新加载他们想要访问的页面/在该页面执行前进、后退操作了。这些操作不会重复提交form,只会对目标页面重复执行GET-req,该操作是安全的

        11-2中的app非常简单,因此user无法从中了解到包含非法mes的POST-form的具体返回细节,但代码也会在/login和/pay-from操作成功时,返回303 See Other。该功能就是由Flask的redirect()提供的。这是所有Web框架中,都应提供的最佳实践。

        11.5.1. 表单使用了错误方法的情况

        误用HTTP方法的Web-app会给自动化工具和浏览器带来问题,执行结果也会与user的预期不同
        在想要进行“读取”操作时,错用了POST方法,这种错误造成的后果没有那么严重。只会影响可用性,不至于删除所有文件。
        如果搜索form错误地使用了POST方法,就无法从浏览器的地址栏中看到真正的查询URL,即通过/search.pl这样的URL来访问我搜索到的那些页面。
        这使得每个查询的URL看上去都是相同的,因此无法共享这些搜索,也无法将他们存为书签。此外,当想通过浏览器的前进/后对操作来浏览其他搜索结果时,总是会弹出弹窗,询问是否真的想要重新提交搜索。楼兰器知道POST操作是可能有副作用的。

        11.5.2. 安全的cookie和不安全的cookie

        11-2终端Web-app试图保护user的隐私。user必须先登录,才能通过路径为"/"的GET-req查看账单列表。如果想通过/pay-form的POST-req来进行支付,user必须要先成功登录。
        假设一个可以访问该web-site的恶意user所进行的操作。
        可先使用自己的账号登录web-site,了解web-site的工作原理。先打开Chrome的调试工具,然后登录web-site,在网络面板中查看req-head与res-headd。
        user在login页面提交了username和pwd后,会从res-mes中得到什么内容?

        HTTP/1.0 302 FOUND
        ...
        Set-Cookie: username=badguy; Path=/
        ...

        成功登录后,返回给浏览器的res-mes中,会包含一个名为username的cookie,username的值被设置为了badguy。只要后续的req中包含该cookie,那么web-site就一定会认为发送这些req的user已经输入了正确的username和pwd
        发送req的Cli可以随意设置这个cookie的值吗?
        恶意user可通过设置浏览器的隐私菜单,来尝试伪造cookie,也可使用Py来尝试访问web-site。可使用Requests先看看是否能获取到首页。
        没有得到授权的req会被重定向到/login页面

        >>> import requests
        >>> r = requests.get('http://localhost:5000/')
        >>> print(r.url)
        http://localhost:5000/login
        如果恶意user将cookie的值设置为brandon,而brandon恰好是一个已经登录的user,结果会怎样?
        >>> r = requests.get('http://localhost:5000/', cookies={'username': 'brandon'})
        >>> print(r.url)
        http://localhost:5000/

        网站信任它已经设置过的cookie,因此会认为该HTTP-req来自已经登录的user:brandon,进而做出res,返回req的页面。恶意user只需知道账单OS的另一个已登录user的username,就能伪造req,向其他任意user支付了。

        >>> r = requests.post('http://localhost:5000/pay',{'account': 'hacker', 'dollars': 100, 'memo': 'Auto-pay'}, cookies={'username': 'brandon'})
        >>> print(r.url)
        http://localhost:5000/?flash=Payment+successful

        伪造成功,已经从brandon的账户中,支付了$100到恶意user控制的账户中。
        这个例子中,学到了宝贵的一课:在设计cookie时,一定要保证user不能自己构造cookie。
        假设user非常聪明,能了解我们用于混淆user的一些手段:Base64编码、交换字母的顺序/使用常量掩码进行简单的异或操作。
        此时,要保证cookie无法被伪造,有3中安全的方法:

        11.5.2.1. 可仍然保留cookie的可读性,但使用数字签名对cookie进行签名。迫使攻击者对此无能为力。他们可从cookie中看到username,也可将他们想要伪造的username重新写入req中。但,由于他们无法伪造数字签名来对req中的cookie进行签名,因此web-site不会信任他们重新构造的cookie

        11.5.2.2. 可对cookie进行完全加密,这样user甚至都无法读懂cookie的值。加密后的cookie是一个人类/PC无法理解/解析的值

        11.5.2.3. 可使用一个纯随机的字符串作为cookie。该str本身没有任何意义,创建该str时,可使用一个标准的UUID库。将这些随机str存储在自己的DB中,每个受信任的user都有一个对应的随机str,之后的req就用该str作为cookie,这样就可通过Serv的认证。如果同一个user发送的多个连续的HTTP-req可能被转发至多态不同的Serv,那么所有Serv都要能访问这一持久化的session存储。有些应用会把session存储在核心DB中,而另一些app则使用Redis/其他存储较短的方式,来防止核心DB的查询负载过高。

        这个示例app中,可利用Flask的内置功能,对cookie进行数字签名,这样就没办法伪造cookie了。部署了真实生产环境的Serv上,需将签名密钥和源代码保存在不同的地方。
        该例中,直接在源代码文件中的顶部给出了签名密钥。如果直接在生产OS的源代码中包含签名密钥,任何能访问版本控制系统的人,都可得到密钥,在DEV机上和DEVOPS过程中都能获取到证书。

        app.secret_key = 'saiGeij8AiS2ahleahMo5dahveixuV3J'

        有了签名密钥后,Flask就会通过session对象来使用该密钥,设置cookie
        session['username'] = username
        session['csrf_token'] uuid.uuid4().hex
        收到req并提取出cookie后,Flask会先检查签名密钥,确认密钥正确后,才会信任此次req。
        如果cookie的签名不正确,就认为该cookie是伪造的,尽管req中提供了cookie,但该cookie无效

        username = session.get('username')

        11.5.2.4.关于cookie,有一点需要注意:不应使用未加密的HTTP传输cookie,否则,处在一家coffee店wifi中的所有人,都能获取到别人的cookie。

        许多web-site在登录时,都会使用HTTPS来安全地传输cookie。登录成功后,浏览器才会直接使用HTTP从同一host处获取所有CSS、JS、图片,cookie只在使用HTTP时是暴露出来的。
        为防止暴露出cookie的情况发生,需要了解选择的Web框架在将cookie发送至浏览器时,是如何设置Secure参数的。正确设置了Secure参数后,就绝不会在非加密的req中包含cookie了。
        这样一来,即使很多人可以查看非加密req的内容,他们也无法从中得到cookie的内容。

        11.5.3. 非持久型跨站脚本

        如果恶意user无法获取/伪造cookie,就无法通过浏览器伪装成另一个user来执行操作。
        如果他们能控制另一个已登录user的浏览器,他们甚至不需查看cookie,只要通过该浏览器来发送req,req中就自动包含正确的cookie。
        要使用这一类型的攻击,至少有3个注明的方法可选。11-2中的Serv无法抵御这3种方法发起的攻击。

        11.5.3.1. 第一种类型是非持久型(nonpersistent)的跨站脚本(XSS, cross-site scripting)。在进行这种攻击时,攻击者自己编写了一些脚本,web-site会把这些脚本看作web-site本身的脚本来运行。

        假设攻击者想向他们的一个账户支付110美元,他们可能会编写11-7所示的JS脚本
        # 11-7 用于支付的attack.js脚本
        

        user在成功登录账单app后,如果页面中包含这段代码,那么代码汇总描述的POST-req就会自动发送,并以受害者user的身份支付账单。因为user无法在最终生成的网页上看到

你可能感兴趣的:(读书笔记_python网络编程3_(11))