環境
python:2.7
Django:1.6
pip install redis
専用URLを発行したくなるタイミング
専用ページというとアカウントでログイン+Cookieが一般的だけど、ログインせずユーザ毎に専用URLを発行したいときがある。例えば初回登録時のメールとか、最近のスマホゲームだとログインIDを発行しないのが一般的なのでユーザへアカウントを発行していません。
想定される攻撃と対策
辞書アタックと総当たり攻撃が怖いので、それさえ対策していれば大丈夫なはず
1. 辞書アタック対策としてURLの文字列がユーザIDから推測できない使い捨て文字列であること
2. 総当たり攻撃対策として1で発行した使い捨て文字列にチェックサムを入れる
↓コード読んだ方が多分早い
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)
実行結果
> 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
この仕組みをセキュリティの観点でレビューすると...
レビューすると、よくこう言われます。『チェックサムだと解析されて危ないんじゃない?』
うちの過疎システムを解析してくださる奇特なハッカー様なんて居ませんよと言いたいところをぐっと堪えてロジックを修正していきます。
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)
実行結果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をログに残しておくと後で楽。
完成品
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)
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)
実行結果
URL発行
>python m.py
http://hogehoge.com/hoge?token=e359b20e-4c60-48da-9294-2ea9fcca0a6c&cs=-2066385284
※URL配布時は必ずURIエンコードしてから配布すること。