Django+redis 1時間だけ有効な専用URLを発行する

# 環境
python:2.7
Django:1.6
pip install redis

# 専用URLを発行したくなるタイミング
専用ページというとアカウントでログイン+Cookieが一般的だけど、ログインせずユーザ毎に専用URLを発行したいときがある。例えば初回登録時のメールとか、最近のスマホゲームだとログインIDを発行しないのが一般的なのでユーザへアカウントを発行していません。

# 想定される攻撃と対策
辞書アタックと総当たり攻撃が怖いので、それさえ対策していれば大丈夫なはず
1. 辞書アタック対策としてURLの文字列がユーザIDから推測できない使い捨て文字列であること
2. 総当たり攻撃対策として1で発行した使い捨て文字列にチェックサムを入れる

↓コード読んだ方が多分早い

```py:m.py
from uuid import uuid4

# 1. 使い捨てIDの発行
_uuid4 = str(uuid4())
print 'uuid4:', _uuid4

# 2. 使い捨てIDのチェックサム
uuid4_char_list = [ord(_char) for _char in _uuid4]
print 'uuid4_char_list:', uuid4_char_list
checksum = sum(uuid4_char_list)
print 'checksum:', checksum

# 3. URL発行
print "http://hoge/page?token={}%20cs={}".format(_uuid4, checksum)

```

```shell-session:実行結果
> python m.py 
uuid4: 6da25bb0-5d9c-4c1e-becc-51e3d5078fe4
uuid4_char_list: [54, 100, 97, 50, 53, 98, 98, 48, 45, 53, 100, 57, 99, 45, 52, 99, 49, 101, 45, 98, 101, 99, 99, 45, 53, 49, 101, 51, 100, 53, 48, 55, 56, 102, 101, 52]
checksum: 2606
http://hoge/page?token=6da25bb0-5d9c-4c1e-becc-51e3d5078fe4%20cs=2606

```

# この仕組みをセキュリティの観点でレビューすると...
レビューすると、よくこう言われます。『チェックサムだと解析されて危ないんじゃない？』
うちの過疎システムを解析してくださる奇特なハッカー様なんて居ませんよと言いたいところをぐっと堪えてロジックを修正していきます。

```py:m.py
# -*- coding: utf-8 -*-
from __future__ import absolute_import, unicode_literals

import binascii
from uuid import uuid4


def checksum_base64_crc(_str):
    """
    入力された文字列を反転し、base64エンコードして、crc32チェックサムを返す
    :param _str: str
    :rtype : int
    """
    # 反転
    _str_r = _str[::-1]

    # base64エンコードしてcrc32チェックサムを取る
    return binascii.crc32(binascii.b2a_base64(_str_r))


# 1. 使い捨てIDの発行
_uuid4 = str(uuid4())
print 'uuid4:', _uuid4

# 2. 使い捨てIDのチェックサム
checksum = checksum_base64_crc(_uuid4)
print 'checksum:', checksum

# 3. URL発行
print "http://hoge/page?token={}%20cs={}".format(_uuid4, checksum)
```

```shell-session:実行結果2
>python m.py 
uuid4: 6a1d87e0-0518-4aa0-a2ca-cced091f254b
checksum: -2147023629
http://hoge/page?token=6a1d87e0-0518-4aa0-a2ca-cced091f254b%20cs=-2147023629
```

# redisで発行したURLを管理する。
tokenが発行されすぎると、DBの肥大化が怖い。時間経過で消えて問題ないデータはredisで管理すると便利。データが時間経過で消えるので、問い合わせ対策として発行したURLをログに残しておくと後で楽。

# 完成品
```py:token_manager.py
# -*- coding: utf-8 -*-
from __future__ import absolute_import, unicode_literals
import binascii
from redis import Redis
from uuid import uuid4


def checksum_base64_crc(_str):
    """
    入力された文字列を反転し、base64エンコードして、crc32チェックサムを返す
    :param _str: str
    :rtype : int
    """
    # 反転
    _str_r = _str[::-1]

    # base64エンコードしてcrc32チェックサムを取る
    return binascii.crc32(binascii.b2a_base64(_str_r))


class IncorrectCheckSumError(Exception):
    # チェックサムが間違っている
    pass


class TokenExpiredError(Exception):
    # Tokenの有効期限切れ
    pass


class TokenRepository(object):
    """
    URL用のTokenをRedisに一定期間保存して管理する
    """
    EXPIRE_SEC = 3600
    _KEY_BASE = "project_name/url/disposable/{}"
    _cli = None

    @property
    def client(self):
        """
        :rtype : Redis
        """
        if self._cli is None:
            self._cli = Redis(host='localhost', port=6379, db=10)
            return self._cli
        return self._cli

    def get_key(self, token):
        return self._KEY_BASE.format(str(token))

    def set(self, token, value):
        self.client.setex(self.get_key(token), value, self.EXPIRE_SEC)
        return

    def get(self, token):
        """
        紐づく値を返却
        :param token:
        :return: str
        """
        value = self.client.get(self.get_key(token))
        return str(value)

    def exist(self, token):
        """
        リポジトリにtokenが存在するか確認する
        :param token: unicode or string
        :rtype: bool
        """
        return bool(self.get(token))


class TokenManager(object):
    @classmethod
    def validate(cls, token, check_sum):
        """
        tokenが正しいかチェックする
        :param token: unicode or str
        :param check_sum: int
        :rtype : bool
        """
        token = str(token)
        check_sum = int(check_sum)

        # チェックサムで正しいtokenか調べる
        if checksum_base64_crc(token) != check_sum:
            raise IncorrectCheckSumError

        user_id = TokenRepository().get(token)
        return bool(user_id)

    @classmethod
    def generate(cls, user_id):
        """
        tokenとchecksumを生成する
        :param user_id:
        :return: str, int
        """
        # 生成
        token = str(uuid4())

        # 生成したtokenとuser_idをredisに紐付けて保存
        TokenRepository().set(token, user_id)

        return token, checksum_base64_crc(token)

    @classmethod
    def get_user_id_from_token(cls, token, check_sum):
        """
        tokenからuser_idを引く
        :param token: str or unicode
        :param check_sum: int
        :rtype: str
        """
        token = str(token)
        if not cls.validate(token, check_sum):
            raise TokenExpiredError
        return TokenRepository().get(token)


# 1. 使い捨てURLを発行
url_base = "http://hogehoge.com/hoge?token={}&cs={}"
user_id = "1111222333"
_token, check_sum = TokenManager.generate(user_id)
url = url_base.format(_token, str(check_sum))
print url

# 2. ロード試験
assert TokenManager.get_user_id_from_token(_token, check_sum) == str(user_id)

```

```py:Django側のView
# -*- coding: utf-8 -*-
from __future__ import absolute_import, unicode_literals
from django.http import HttpResponse
from django.views.generic import View


class ExclusiveView(View):
    def get(self, request, *args, **kwargs):
        # HTTP GET
        token = request.GET.get("token", None)
        check_sum = request.GET.get("cs", None)

        # tokenからuser_idを引く
        error_message = None
        message = None
        try:
            user_id = TokenManager.get_user_id_from_token(token, check_sum)
            message = "あなたのuser_idは{}です".format(user_id)
        except IncorrectCheckSumError:
            error_message = "不正なパラメーターです"
        except TokenExpiredError:
            error_message = "ページの有効期限切れ"
        if error_message:
            message = error_message

        return HttpResponse(message,
                            mimetype='text/html; charset=UTF-8',
                            *args, **kwargs)
```

# 実行結果
```shell-session:URL発行
>python m.py 
http://hogehoge.com/hoge?token=e359b20e-4c60-48da-9294-2ea9fcca0a6c&cs=-2066385284
```
※URL配布時は必ずURIエンコードしてから配布すること。

■ブラウザでアクセス 正常系
![スクリーンショット 2015-10-15 15.20.04.png](https://qiita-image-store.s3.amazonaws.com/0/65312/0026ed0a-8aa5-5078-0add-c6179f0bd162.png)

■ブラウザでアクセス 異常系
![スクリーンショット 2015-10-15 15.20.16.png](https://qiita-image-store.s3.amazonaws.com/0/65312/3a377551-44a0-766c-2e82-e88eeee1bf14.png)