前回、書籍「Webサーバを作りながら学ぶ 基礎からのWebアプリケーション開発入門」をもとに、PythonでURLルーティング機能だけがあるWSGIフレームワークを自作しました。
今回は、以下のRFC6265やWebサイトを参考に、前回のWSGIフレームワークにCookieを扱える機能を追加してみます。
目次
環境
- Windows10 x64
- Python 3.5.2 32bit
Webサーバへ機能追加
前回までと同様、自作WSGIフレームワークは、WSGI準拠の自作Webサーバ上で動作させます。
ただ、前回までの自作WebサーバではCookieを扱うことができないため、機能を追加します。
Cookieを扱えるようにする
UAからCookieを含むHTTPリクエストを自作Webサーバへ送信したところ、
GET / HTTP/1.1 ... Cookie: visit=1
というリクエストラインが取得できました。
Cookieとセッション管理 - Qiita
RFC6265にて、UAからサーバへはCookieヘッダをいくつ送信可能なのかを調べたところ、
UA は HTTP リクエストを生成する際に、複数の Cookie ヘッダを添付してはならない。
5.4. Cookie ヘッダ | RFC 6265 — HTTP State Management Mechanism (日本語訳)
との記載がありました。一つだけと考えて良さそうです。
続いて、WSGIサーバからWSGIフレームワークへCookieを渡す方法を探したところ、接頭辞HTTP
を持つ変数をenviron辞書へ設定すれば良さそうでした。
PEP 3333: Python Web Server Gateway Interface v1.0.1 — knzm.readthedocs.org 2012-12-31 documentation
そこで
# "Cookie: "を削除 HTTP_HEADER_COOKIE = 'Cookie:' request_cookies = [line.decode('utf-8').replace(HTTP_HEADER_COOKIE, '').lstrip() for line in byte_request_lines if line.decode('utf-8').find(HTTP_HEADER_COOKIE) == 0] # environ辞書へ設定 env['HTTP_COOKIE'] = request_cookies[0]
のように実装しました。
サーバで生成するDateヘッダに、現在時刻をセットする
今まで、自作Webサーバでは固定値をセットしていました。
今回、CookieのMax-Age属性を試すときに分かりやすくするため、以下を参考に現在時刻を設定するように修正します。
http - RFC 1123 Date Representation in Python? - Stack Overflow
以上でWSGIサーバの機能追加は完了です。
WSGIフレームワークへ機能追加
自作WSGIフレームワークに対し、Cookieを扱う機能を追加します。
PythonでCookieを簡単に扱う方法を調べたところ、標準ライブラリにあるhttp.cookies.SimpleCookie
を使うのが良さそうでした。
21.23. http.cookies — HTTPの状態管理 — Python 3.5.2 ドキュメント
文字列からSimpleCookieオブジェクトを生成する方法を調べるためにソースコードを読んでみました。
SimpleCookieクラスの__init__()
メソッドに文字列を渡すと内部ではload()
メソッドが呼ばれることから、文字列からSimpleCookieオブジェクトを生成できていました。
cpython/cookies.py - GitHub
def __call__(self, environ, start_response): self.cookie = SimpleCookie(environ['HTTP_COOKIE']) if 'HTTP_COOKIE' in environ else None # ...
として、SimpleCookieオブジェクトを保持することにしました。
WSGIアプリへ機能追加
Cookieの挙動確認
機能を追加する前に、Cookieの挙動を確認します。
Cookieの属性について
RFC6265にて、
クッキーの属性は、 UA からサーバへは返されないことに注意。 特に,サーバは、単独の Cookie ヘッダからは,クッキーが[ いつ失効するのか?/ どのホストに対して有効なのか?/ どのパスに対して有効なのか?/ Secure や HttpOnly 属性を伴って設定されたものかどうか? ]を決定できない。
4.2.2. 意味論 | RFC 6265 — HTTP State Management Mechanism (日本語訳)
と記載されているため、その挙動を確認します。
cookie_test_set_property_only_once.py
# python server.py cookie_test_set_property_only_once:app from http.cookies import SimpleCookie from no1_1_cookie_framework import MyWSGIFramework def cookie(environ, start_response): visit_in_html = 1 headers = [('Content-Type', 'text/plain'),] if app.cookie: visit_in_html = int(app.cookie['http_only'].value) + 1 else: http_only = SimpleCookie('http_only=1') http_only['http_only']['httponly'] = True headers.append(('Set-Cookie', http_only.output(header=''))) start_response('200 OK', headers) return ["Hello, No.{}".format(visit_in_html).encode('utf-8')] # URLルーティング設定 app = MyWSGIFramework([ ('/', cookie), ])
というWSGIアプリを作成・実行し、ヘッダを確認してみたところ、
# 一回目 ## Request Headers GET / HTTP/1.1 Host: localhost:8888 ... ## Response Headers HTTP/1.1 200 OK Content-Type: text/plain Set-Cookie: http_only=1; HttpOnly Date: Wed, 28 Sep 2016 08:06:36 GMT Server: MyWSGIServer 0.3 #------------------------------ # 二回目 ## Request Headers GET / HTTP/1.1 Host: localhost:8888 ... Cookie: http_only=1 ## Response Headers HTTP/1.1 200 OK Content-Type: text/plain Date: Wed, 28 Sep 2016 08:06:53 GMT Server: MyWSGIServer 0.3
のように、二回目のRequest HeaderのCookieヘッダにはHttpOnly属性が含まれていませんでした。
CookieのMax-Age属性について
Max-Age属性ではCookieが失効するまでの秒数を指定できるため、この挙動も確認してみます。
cookie_test_set_max_age.py
# python server.py cookie_test_set_max_age:app from http.cookies import SimpleCookie from no1_1_cookie_framework import MyWSGIFramework def cookie(environ, start_response): visit_in_html = 1 headers = [('Content-Type', 'text/plain'),] if app.cookie: visit_in_html = int(app.cookie['http_only'].value) + 1 else: http_only = SimpleCookie('http_only=1') http_only['http_only']['max-age'] = 5 headers.append(('Set-Cookie', http_only.output(header=''))) start_response('200 OK', headers) return ["Hello, No.{}".format(visit_in_html).encode('utf-8')] # URLルーティング設定 app = MyWSGIFramework([ ('/', cookie), ])
というWSGIアプリを作成し、指定した秒数以上(今回は5秒以上)待ってから二回目のリクエストを送ったところ、
# 一回目 ## Request Headers GET / HTTP/1.1 Host: localhost:8888 Connection: keep-alive Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.116 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8 Accept-Encoding: gzip, deflate, sdch Accept-Language: ja,en-US;q=0.8,en;q=0.6 ## Response Headers HTTP/1.1 200 OK Content-Type: text/plain Set-Cookie: http_only=1; Max-Age=5 Date: Wed, 28 Sep 2016 08:39:58 GMT Server: MyWSGIServer 0.3 #------------------------------ # 二回目 ## Request Headers GET / HTTP/1.1 Host: localhost:8888 Connection: keep-alive Cache-Control: max-age=0 Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.116 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8 Accept-Encoding: gzip, deflate, sdch Accept-Language: ja,en-US;q=0.8,en;q=0.6 ## Response Headers HTTP/1.1 200 OK Content-Type: text/plain Set-Cookie: http_only=1; Max-Age=5 Date: Wed, 28 Sep 2016 08:40:51 GMT Server: MyWSGIServer 0.3
と、二回目のRequest HeadersにCookieヘッダが含まれていませんでした。
複数のSet-Cookieヘッダを作成する機能を追加
RFC6265より、複数のCookieがある場合、その分Set-Cookie応答ヘッダを用意する必要があります。
Set-Cookie 応答ヘッダは[ ヘッダ名 "Set-Cookie", 文字 ":", 1個のクッキー ]の並びからなる。 各クッキーは、1個の name-value-pair ([ 名前 cookie-name, 値 cookie-value ]の組)から開始され,ゼロ個以上の属性([ 名前 attribute-name, 値 attribute-value ]の組)が後続する。
4.1.1. 構文 | RFC 6265 — HTTP State Management Mechanism (日本語訳)
そこで、複数のCookieがあるapp.cookie(SimpleCookieオブジェクト)の値を、output()
メソッドを使って確認してみます。
BaseCookie.output() | 21.23. http.cookies — HTTPの状態管理 — Python 3.5.2 ドキュメント
print('-'*10) print('app_cookie:\n{}\n'.format(app.cookie.output(header=''))) print('-'*10)
結果は、
---------- app_cookie: hoge=fuga visit=2; HttpOnly ----------
となり、各Cookieが「CRLF + 半角スペース」で連結された状態で取得できました。
今回、HTTPレスポンスヘッダはheaders.append(('Set-Cookie', '<Cookie名>=<Cookie値>'))
という形で作成するため、
server_cookies = app.cookie.output(header='').split('\r\n') for sc in server_cookies: headers.append(('Set-Cookie', sc))
としました。
Cookieの処理をWSGIアプリからWSGIフレームワークへ移動
今までの実装では、Cookieの処理はWSGIアプリ側で行われていました。
これではWSGIアプリを作成するごとにCookieの処理も実装しなければならないため、WSGIフレームワーク側へと移動してみます。
の3つのメソッドを用意します。
def set_cookie(self, key, value, **kwargs): self.cookie[key] = value for k, v in kwargs.items(): # max-age属性は引数名「max_age」として設定されてくる前提 # Pythonではハイフンが引数名で使えないため self.cookie[key][k.replace('_', '-')] = v def get_cookie(self, key): return self.cookie.get(key, None) def generate_cookie_header(self): # HTTPリクエストヘッダのCookieに複数のCookieが含まれる場合、Cookie名ごとにSet-Cookieヘッダを生成する headers = [] response_cookies = self.cookie.output(header='').split('\r\n') for cookie in response_cookies: headers.append(('Set-Cookie', cookie)) return headers
それを使用するWSGIアプリは
def cookie(environ, start_response): visit_in_html = 1 headers = [('Content-Type', 'text/plain'),] if app.cookie: v = app.get_cookie('visit') if v: visit_in_html = int(v.value) + 1 app.set_cookie('visit', visit) else: app.set_cookie('visit', 1) app.set_cookie('fuga', 'fugafuga', httponly=True) else: app.set_cookie('visit', 1) # Cookie値の他、max-age属性もセットしてみる app.set_cookie('hoge', 'hogehoge', max_age=10) # Cookie値の他、httponly属性もセットしてみる app.set_cookie('fuga', 'fugafuga', httponly=True) # ここまでで作成したCookieをCookieヘッダに設定する # リストにリストを追加するので、append()ではなくextend()を使う headers.extend(app.generate_cookie_header()) start_response('200 OK', headers) return ["Hello, No.{}".format(visit_in_html).encode('utf-8')]
として、Cookie処理をWSGIフレームワーク側へと移動しました。
ソースコード
GitHubに上げました。cookie
ディレクトリの下が今回のソースコードになります。
thinkAmi-sandbox/wsgi_framework-sample