Pythonで、Cookieを扱うWSGIフレームワークを自作してみた

前回、書籍「Webサーバを作りながら学ぶ 基礎からのWebアプリケーション開発入門」をもとに、PythonでURLルーティング機能だけがあるWSGIフレームワークを自作しました。

gihyo.jp

 
今回は、以下の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を扱う機能を追加します。

PythonCookieを簡単に扱う方法を調べたところ、標準ライブラリにあるhttp.cookies.SimpleCookieを使うのが良さそうでした。
21.23. http.cookies — HTTPの状態管理 — Python 3.5.2 ドキュメント

 
文字列からSimpleCookieオブジェクトを生成する方法を調べるためにソースコードを読んでみました。

SimpleCookieクラスの__init__()メソッドに文字列を渡すと内部ではload()メソッドが呼ばれることから、文字列からSimpleCookieオブジェクトを生成できていました。
cpython/cookies.py - GitHub

 
そのため、自作WSGIフレームワークでは

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フレームワーク側へと移動してみます。

 
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