TOC

安全的 Cookie

Cookie

本文讲的是 Tornado 框架中的 “secure cookie” 实现。

效果

user =
  "2|1:0|10:1545880175|4:user|4:MTA=|98ba9d8916cdb090dc573d3008185f2b0a5b41a7b420249f56398cf2aab2d8dd";

竖线隔开,长度:值 的格式,拆分一下的话就是 5 段:

  1. 2 版本,这个比较特殊,没有长度
  2. 1:0 次版本,保留字段,默认 0
  3. 10:1545880175 时间戳,服务器当地时间(默认:time.time),精确到秒
  4. 4:user 字段名
  5. 4:MTA= 字段值,Base64 编码,解析过来就是 10
  6. 98ba9d8916cdb090dc573d3008185f2b0a5b41a7b420249f56398cf2aab2d8dd

Cookie 封装

class RequestHandler(object):
    def set_secure_cookie(self, name, value, expires_days=30, version=None, **kwargs):
        self.set_cookie(name, self.create_signed_value(name, value, version=version),
                        expires_days=expires_days, **kwargs)

    def create_signed_value(self, name, value, version=None):
        self.require_setting("cookie_secret", "secure cookies")
        return create_signed_value(self.application.settings["cookie_secret"],
                                   name, value, version=version)

    def get_secure_cookie(self, name, value=None, max_age_days=31, min_version=None):
        self.require_setting("cookie_secret", "secure cookies")
        if value is None:
            value = self.get_cookie(name)
        return decode_signed_value(self.application.settings["cookie_secret"],
                                   name, value, max_age_days=max_age_days,
                                   min_version=min_version)

加密方法

思路就是加个签名,防止伪造、篡改。

加密方法有两个版本:

  1. v1 值|时间戳|签名 将原来的值 Base64 之后带上时间戳和签名
    签名算法:sha1
    签名内容:字段名 字段值 时间戳 (没有空格隔开)
  2. v2 版本|次版本|时间戳|字段名|值|签名,同时,每一段都采用 长度:内容 的格式 (就是开头举得例子)
    签名算法:sha256
    签名内容:版本|次版本|时间戳|字段名|值|

第二版 是 2014 年 v3.2.1 引入 (dd7bc4),设计更加周全,忽略第一版,就研究一下第二版的实现:

import time, base64, hmac, hashlib

def utf8(value):
    if isinstance(value, bytes):
        return value
    return value.encode("utf-8")

def create_signed_value(secret, name, value):
    timestamp = str(int(time.time())).encode('ascii')
    value = base64.b64encode(utf8(value))
    def format_field(s):
        return utf8("%d:" % len(s)) + utf8(s)
    to_sign = b"|".join([
        b"2|1:0",
        format_field(timestamp),
        format_field(name),
        format_field(value),
        b''])
    signature = create_signature(secret, to_sign)
    return to_sign + signature

def decode_signed_value(secret, name, value, max_age_days=31):
    def _consume_field(s):
        length, _, rest = s.partition(b':')
        n = int(length)
        field_value = rest[:n]
        # In python 3, indexing bytes returns small integers; we must
        # use a slice to get a byte string as in python 2.
        if rest[n:n + 1] != b'|':
            raise ValueError("malformed v2 signed value field")
        rest = rest[n + 1:]
        return field_value, rest

    # 切割
    rest = value[2:]  # remove version number
    try:
        key_version, rest = _consume_field(rest)
        timestamp, rest = _consume_field(rest)
        name_field, rest = _consume_field(rest)
        value_field, rest = _consume_field(rest)
    except ValueError:
        return None

    # 签名验证
    # 我觉得这个操作应该放在字段名校验和有效期校验之后
    passed_sig = rest
    signed_string = value[:-len(passed_sig)]
    expected_sig = create_signature(secret, signed_string)
    if not hmac.compare_digest(passed_sig, expected_sig):
        return None

    # 字段名验证:第一版没有这个
    if name_field != utf8(name):
        return None

    # 有效期校验
    timestamp = int(timestamp)
    if timestamp < time.time() - max_age_days * 86400:
        return None

    try:
        return base64.b64decode(value_field)
    except Exception:
        return None

def create_signature(secret, s):
    hash = hmac.new(utf8(secret), digestmod=hashlib.sha256)
    hash.update(utf8(s))
    return utf8(hash.hexdigest())

print(create_signature('hello', 'world'))
# b'f1ac9702eb5faf23ca291a4dc46deddeee2a78ccdaf0a412bed7714cfffb1cc4'
encoded = create_signed_value('hello', 'user', '10')
print(encoded)
# b'2|1:0|10:1545880245|4:user|4:MTA=|680337e612a4360e74343b00f979586fbc7a376a9b2e0c13f44389a5d5085efa'
print(decode_signed_value('hello', 'user', encoded))
# b'10'