Google App Engine for Python でセッション管理

2011-03-25 追記ここから:

下のコードではDatastoreエンティティのkey値をセッションIDに使用していますが、これはセッションハイジャック脆弱性が生じてしまうので避けるべきだと気付きました。

key値は一見ランダムな文字列に見えますが、同じkindのエンティティのkey値は末尾が少し変わる程度なので、cookieに書き込んであるセッションIDから他人のセッションIDが類推できてしまいますね。

uuid.uuid4()等でランダムな値を生成する方法をとった方がよさそうです(下のコードはそのようには修正していません)

追記ここまで。


Google App Engine for Pythonで独自にセッション管理をするためのサンプルプログラムは検索したらいくつか見つかったのですが、ちょっと僕にはむずかしすぎるというか、何がどうなってそうなるのかサッパリわからなかったので、自分で理解できる範囲で書いてみました。

デキる人から見たらかなりアレだと思いますので、お気づきの点があったらぜひご指摘いただけるとうれしいです><

それと、HTMLのテンプレートまでここに書いたら長大なエントリーになってしまうので割愛してますが、なにかいい方法ないものでしょうか。どこかにまとめてアップロードすればいいのかな??

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import os, re, datetime, hashlib

from google.appengine.ext import db
from google.appengine.api import memcache
from google.appengine.ext import webapp
from google.appengine.ext.webapp import template
from google.appengine.ext.webapp.util import run_wsgi_app

class User(db.Model):
# モデル(ユーザー)
    name = db.StringProperty()
    password = db.StringProperty()

class Session(db.Model):
# モデル(セッション)
    user = db.ReferenceProperty()

def getSession(request):
# クッキーのセッションIDとmemcache/Datastoreのセッション情報を照合する関数
# リクエストハンドラーのself.requestを引数として受け取る

    # クッキーからセッション番号を取得
    sid = request.cookies.get('SID', '')
    if sid:
        # まずmemcacheにそのセッション番号があるか探す
        session = memcache.get(sid)
        if session:
            return session

        # memcacheになければDatastoreから探す
        else:
            session = Session.get(sid)
            if session:
                # Datastoreにあればmemcacheにも入れておく
                memcache_key = str(session.key())
                memcache_value = {
                    'user' : {
                        'key' : str(session.user.key()),
                        'name' : session.user.name,
                    },
                }
                memcache.add(key=memcache_key, value=memcache_value, time=3600)

                return session


class ViewHome(webapp.RequestHandler):
# ホーム画面のView

    def get(self):
        # セッション情報を取得してテンプレート値としてセット
        template_values = {
            'session' : getSession(self.request),
        }
        path = os.path.join(os.path.dirname(__file__), 'templates/home.html')
        self.response.out.write(template.render(path, template_values))


class ViewSignup(webapp.RequestHandler):
# ユーザー登録画面のView

    def get(self):
        # セッション情報を取得してテンプレート値としてセット
        template_values = {
            'session' : getSession(self.request),
        }
        path = os.path.join(os.path.dirname(__file__), 'templates/signup.html')
        self.response.out.write(template.render(path, template_values))

    def post(self):
        # エラー表示の関数
        def viewError():
            template_values = {
                'error_messages' : error_messages,
                'error' : {
                    'name' : name,
                    'password' : password,
                }
            }

            path = os.path.join(os.path.dirname(__file__), 'templates/signup.html')
            self.response.out.write(template.render(path, template_values))

        # フォームからの値を受け取り
        name = self.request.get('name')
        password = self.request.get('password')

        # エラーメッセージのリスト
        error_messages = []

        # ユーザー名が半角6文字以上、32文字以下でなければエラーメッセージを設定
        if len(name) < 6 or len(name) > 32:
            error_messages.append('ユーザー名は6文字以上、32文字以下でお願いします。')

        # ユーザー名は半角英数・アンダースコアのみでなければエラーメッセージを設定
        match = re.search("([^\w])",name)
        if match:
            error_messages.append('ユーザー名は半角英数字と_(アンダースコア)でお願いします。')

        # パスワードは4文字以上、64文字以下でなければエラーメッセージを設定
        if len(password) < 4 or len(password) > 64:
            error_messages.append('パスワードは4文字以上、32文字以下でお願いします。')

        # パスワードは半角英数字のみでなければエラーメッセージを設定
        match = re.search("([^A-Za-z0-9])", password)
        if match:
            error_messages.append('パスワードは半角英数字でお願いします。')

        # ここまででエラーメッセージが1つでも設定されていたらエラー表示
        if error_messages:
            viewError()

        # ここまででエラーメッセージが1つもなければ...
        else:

            # 既に同じユーザー名が使用されていないかチェック
            user = User.all().filter('name = ', name).get()
            if user:
                error_messages.append('そのユーザー名はすでに使われています。')
                viewError()

            # 同じユーザー名が使用されていなければユーザー登録
            else:

                # パスワードをハッシュにする
                password = hashlib.sha1(password).hexdigest()

                # ユーザーを登録
                user = User(
                    name = name,
                    password = password,
                )
                user_key = user.put()

                template_values = {}
                path = os.path.join(os.path.dirname(__file__), 'templates/signup_done.html')
                self.response.out.write(template.render(path, template_values))


class ViewSignin(webapp.RequestHandler):
# ログイン画面のView

    def get(self):
        # セッション情報を取得してテンプレート値としてセット
        template_values = {
            'session' : getSession(self.request),
        }
        path = os.path.join(os.path.dirname(__file__), 'templates/signin.html')
        self.response.out.write(template.render(path, template_values))

    def post(self):

        # エラー表示の関数
        def viewError():
            template_values = {
                'error' : {
                    'name' : name,
                    'password' : password,
                }
            }

            path = os.path.join(os.path.dirname(__file__), 'templates/signin.html')
            self.response.out.write(template.render(path, template_values))

        # エラー判定用の変数(1ならエラーになる)
        error = 0;

        # フォームからの値を取得
        name = self.request.get('name')
        password = self.request.get('password')

        # ユーザー名が6文字未満か32文字より多ければエラー判定を設定
        if len(name) < 6 or len(name) > 32:
            error = 1

        # ユーザー名が半角英数とアンダースコア以外を含んでいたらエラー判定を設定
        match = re.search("([^\w])",name)
        if match:
            error = 1

        # パスワードが4文字未満か64文字より多ければエラー反映を設定
        if len(password) < 4 or len(password) > 64:
            error = 1

        # パスワードが半角英数以外を含んでいたらエラー判定を設定
        match = re.search("([^A-Za-z0-9])", password)
        if match:
            error = 1

        # ここまででエラー判定が設定されていたらエラー表示
        if error:

            # エラー表示
            viewError()

        # ここまででエラー判定が設定されていなければ...
        else:

            # 登録済みユーザーかどうかチェック
            user = User.all().filter('name = ', name).get()

            # 未登録ユーザーならエラー表示
            if not user:
                viewError()

            # 登録済みユーザーなら...
            else:

                # パスワードのハッシュを作成
                password_hashed = hashlib.sha1(password).hexdigest()

                # Datastorに登録されているパスワードのハッシュが一致したら
                if password_hashed == user.password:

                    # Datastoreにセッション情報を保存
                    session_key = Session(
                        user = user,
                    ).put()

                    # memcacheにセッション情報を保存
                    memcache_key = str(session_key)
                    memcache_value = {
                        'user' : {
                            'key' : str(user.key()),
                            'name' : user.name,
                        },
                    }
                    memcache.add(key=memcache_key, value=memcache_value, time=3600)

                    # クッキーをセット
                    expires_date = datetime.datetime.utcnow() + datetime.timedelta(365)
                    expires = expires_date.strftime("%d %b %Y %H:%M:%S GMT")
                    self.response.headers.add_header(
                       'Set-Cookie',
                       'SID=' + str(session_key) + '; expires=' + expires
                    )

                    # ホームに飛ばす
                    self.redirect('/')

                else:
                    # エラー表示
                    viewError()

class ViewSignout(webapp.RequestHandler):
# ログアウト処理

    def get(self):
        template_values = {}

        sid = self.request.cookies.get('SID', '')

        # memcacheのクリア
        memcache.delete(sid)

        # DataStoreエンティティの削除
        db.delete(sid)

        # cookieの削除
        self.response.headers.add_header(
            'Set-Cookie',
            'SID=0;expires=Fri, 01-Jan-1950 00:00:00 GMT'
        )

        # ホームに飛ばす
        self.redirect('/')


class ViewPage1(webapp.RequestHandler):
# ページ1画面のView

    def get(self):
        # セッション情報を取得してテンプレート値としてセット
        template_values = {
            'session' : getSession(self.request),
        }
        path = os.path.join(os.path.dirname(__file__), 'templates/page1.html')
        self.response.out.write(template.render(path, template_values))


class ViewPage2(webapp.RequestHandler):
# ページ2画面のView

    def get(self):
        # セッション情報を取得してテンプレート値としてセット
        template_values = {
            'session' : getSession(self.request),
        }
        path = os.path.join(os.path.dirname(__file__), 'templates/page2.html')
        self.response.out.write(template.render(path, template_values))


# URLとViewの対応
application = webapp.WSGIApplication([
    ('/page2/?$', ViewPage2),
    ('/page1/?$', ViewPage1),
    ('/signout/?$', ViewSignout),
    ('/signin/?$', ViewSignin),
    ('/signup/?$', ViewSignup),
    ('/$', ViewHome),
],debug=True)


def main():
  run_wsgi_app(application)

if __name__ == "__main__":
  main()