Django: Session

28th April 2021 at 6:00pm
Django

Django session 系统的实现。

先看 Django 官方文档 理解其提供的功能。Session 系统实现细节很多,比如支持匿名 session 时,要考虑用户从匿名变登陆后,服务端 session 数据的保持等。因此先理解该系统提供的功能是必要的。

下面结合代码讲解一个请求到 Django 后的整个处理流程。

用户首次打开 Django 站点

此时用户请求中的 cookie 并不带 session ID(cookie key 为 sessionid)。假如 Django 在处理此请求过程中并没有向 request.session 写入数据,那回包也不会下发 session ID cookie。

假如用户后续使服务端 Django 写入了 session 数据,比如用户在未登陆状态下将物品添加进了购物车,那么 Django 会生成 session ID 并通过 cookie 下发给用户。

如果用户后续登陆了,这些 session 数据会被保留。

功能划分

Session 中间件的代码位于 django.contrib.sessions.middleware 包,主要由两块组成:

  • middleware.py
    • 处理请求包中的 session cookie
    • 根据 session 的属性(是否 browser session,过期时间多长等),向回包写入 session cookie,以及 Vary 头等
  • backends 包:
    • 安全相关的处理,比如校验 session 是否属于该 user,配合 auth 模块防范 session fixation 等
    • 提供 request.session 供操作 session 数据
    • 从持久性存储(backend)读取和写入
    • 实现内置的 serializer 来序列化 session data

request.session 是一个 dict-like 对象,封装了对底层 session store 的调用,使用户并不用关心底层细节。比如用户将物品加入购物车,你可以这样写:

request.session['cart'].append({'sku': abc, 'quantity': 1})

请求带 Session Cookie 到后台

先通过 session middleware 的 process_request()

# django.contrib.sessions.middleware.SessionMiddleware.process_request
def process_request(self, request):
    session_key = request.COOKIES.get(settings.SESSION_COOKIE_NAME)
    request.session = self.SessionStore(session_key)

之后框架对 request.session 的操作(比如 .get())均由 session store 处理。大多数的逻辑都在 SessionBase 中处理。下面的逻辑在 django.contrib.sessions.backends.base.SessionBase 中。

当你使用 request.session.get() 时:

def get(self, key, default=None):
    return self._session.get(key, default)

self._session 是一个 lazy load 的属性,会调用 self.load()

def _get_session(self, no_load=False):
    """
    Lazily load session from storage (unless "no_load" is True, when only
    an empty dict is stored) and store it in the current instance.
    """
    self.accessed = True
    try:
        return self._session_cache
    except AttributeError:
        if self.session_key is None or no_load:
            self._session_cache = {}
        else:
            self._session_cache = self.load()
    return self._session_cache

_session = property(_get_session)

由于存储跟具体的 SessionStore 有关,SessionStore 需要继承 SessionBase,并实现这些方法:

def exists(self, session_key):
    """
    Return True if the given session_key already exists.
    """
    raise NotImplementedError('subclasses of SessionBase must provide an exists() method')

def create(self):
    """
    Create a new session instance. Guaranteed to create a new object with
    a unique key and will have saved the result once (with empty data)
    before the method returns.
    """
    raise NotImplementedError('subclasses of SessionBase must provide a create() method')

def save(self, must_create=False):
    """
    Save the session data. If 'must_create' is True, create a new session
    object (or raise CreateError). Otherwise, only update an existing
    object and don't create one (raise UpdateError if needed).
    """
    raise NotImplementedError('subclasses of SessionBase must provide a save() method')

def delete(self, session_key=None):
    """
    Delete the session data under this key. If the key is None, use the
    current session key value.
    """
    raise NotImplementedError('subclasses of SessionBase must provide a delete() method')

def load(self):
    """
    Load the session data and return a dictionary.
    """
    raise NotImplementedError('subclasses of SessionBase must provide a load() method')

@classmethod
def clear_expired(cls):
    """
    Remove expired sessions from the session store.

    If this operation isn't possible on a given backend, it should raise
    NotImplementedError. If it isn't necessary, because the backend has
    a built-in expiration mechanism, it should be a no-op.
    """
    raise NotImplementedError('This backend does not support clear_expired().')

一个用户 session 的数据结构是

  • Session key:用站点 SECRET 及用户 hash 过的密码再 hash 出来的字符串
  • Session data:服务端存储的业务数据,即 request.session 操作的数据
    • 这个数据由落入存储时要再经过 SessionBase.encode(),后面安全一节详谈
  • Expire age:读取及设置过期 时长(比如 30 天)的能力

过期时长在这些地方被使用

  • 具体的 session store 会使用:
    • Database store 将过期时间存入数据库,用于未来判断该 session 是否仍然有效
    • Redis store(非内置)可以用其设置 key 的 exipre time
  • middleware.py 中为 cookie 设置有效时间

但具体的 session store 可能用 过期时长过期时间 来落存储(.save()),只要它们 .load() 时能转换成过期时长即可。比如数据库适合用过期时间,这样方便 SQL 做筛选;Redis 适合用过期时长,方便设置 expire。

安全

安全分几块:

  • Session 存储相关
  • 用户登陆态相关

Session 存储相关

上文提到 session 数据分 3 块:session key,session data 及 expire age。其中:

  • Session key:
    • 需要通过 cookie 给到前端
    • 需要存储到 session store,使 store 可以通过此 key 筛选出 session data
    • Django 使用随机生成的 32 位字符串实现
  • Expire age 敏感度较低,一般无需特殊处理

Session data 单独拿出来讲。因为 Django 支持以 cookie 作为 session store,因此 session data 需要通过 cookie 在 client 与 server 间传输。为此 Django 对 session data 做了压缩、加了签名,而且不管是不是 cookie 作为 store 都会执行此逻辑。代码在 SessionBase.encode().decode() 处。

encode() 的处理很复杂。下面 Django 3.2 的处理流程。最终生成的 signed_value 被写入 store:

# django.core.signing.Signer.sign_object
value = base64_encode(
    compress(
        serializer.dump(
            session_data_dict
        )
    )
)

# django.core.signing.TimestampSigner.sign
value_with_timestamp = value + ":" + now_timestamp

# django.core.signing.Signer.signature
signature = base64_encode(
    salted_hmac(
        some_fixed_salt, value_with_timestamp, settings.SECRET as key, "sha256"
    )
)

# django.core.signing.Signer.sign
signed_value = value_with_timestamp + ":" + signature

这套流程体现了 Django 的 设计理念,在官方文档这 一节 中的 WARNING 处描述。重点是:

  • SECRET 作为盐保证了只有你的站点能生成合法的 signature
    • 假如你用 DB 作为 store,而 DB 被攻破,那么:
      • 黑客无法伪造 session data
      • 即使黑客使用自己的 session data 覆盖别人的,也不起作用(下文详述)
  • Session 数据没有被加密
    • 假如你用 DB 作为 store,而 DB 被攻破,那么 session data 可以被解出来,黑客还可以知道 session 属于哪个用户(下文详述)

用户登陆态相关

安全这块跟 auth 模块 紧密相关。登陆相关的实现入口在 django.contrib.auth.get_user

用户登录成功后,Django auth 模块会在 request.session 中写入 3 个数据,例如:

_auth_user_id1
_auth_user_backenddjango.contrib.auth.backends.ModelBackend
_auth_user_hash23b67b81196ced88b99560372436906edfa5b3bf

其中 _auth_user_hash 是以固定串加上站点 SECRET_KEY 为盐,对用户存储在数据库中的密码(哈希过的,见 Auth: Authentication: Password)生成的 HMAC 哈希值。代码实现在 django.contrib.auth.base_user.AbstractBaseUser.get_session_auth_hash

这个 hash 值存在的意义是:

  • 登陆的用户每次有请求过来时,Django 会校验其 session 中的 hash 与 password 算出来的值是否一致;如果不一致,会将当前的 session 清除,request.user 置 None,用户的登陆态即消失
    • 处理逻辑的入口在 django.contrib.auth.middleware.AuthenticationMiddleware.process_request
  • 用户在修改密码后,会将其原有的 session 清理并生成新的,然后就新的 hash 写入 session
    • 结合上一点,修改密码后,用户的各 session 都会在下次请求时被清除

另外 auth 模块也处理 session fixation(详见 Session Management)。用户被黑客注入 session key 后,当用户发起登陆请求时,Django 会校验其 session key 关联的 _user_auth_hash 是否属于此用户(这是有可能的,比如未受攻击情况下,在未登录状态下同时打开了两个登录页面,其中一个成功登录,又在另一个上做登录时),如果不属于此用户,会把原有的 session 清理掉,再新建一个 session。这样做使得黑客无法与用户共享同个 session。

See Also