Files
iAOP/core/auth/session.py

98 lines
3.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""④-1 会话管理:登录 token 签发/校验 —— issue #150 / PRD 8.2。
无状态会话令牌(self-contained token),服务端无需存 session 表即可校验,
适合配置台这种轻量场景:
- 令牌格式:`<user_id>.<expire_epoch>.<hmac_sha256_sig>`(base64url 友好);
- 签名密钥来自部署环境(`IAOP_AUTH_SECRET`),未配置时随机生成(进程内有效,
重启失效——生产必须配置固定密钥以支持多副本);
- 校验恒定时间(`hmac.compare_digest`),过期/签名不符一律判无效;
- `SESSION_COOKIE` 为会话 cookie 名(`iaop_session`,HttpOnly + SameSite=Lax)。
PRD 8.2 验收口径:未携带有效 token 的请求,写操作一律拒绝(见 auth_api.py)。
"""
from __future__ import annotations
import base64
import hmac
import hashlib
import os
import time
from dataclasses import dataclass
from typing import Optional, Tuple
SESSION_COOKIE = "iaop_session"
DEFAULT_TTL = 8 * 3600 # 8 小时
HASH_BYTES = 32
def _b64url(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii")
def _b64url_decode(s: str) -> bytes:
pad = "=" * (-len(s) % 4)
return base64.urlsafe_b64decode(s + pad)
def _secret() -> bytes:
"""会话签名密钥。优先环境变量,否则进程内随机(重启失效)。"""
env = os.environ.get("IAOP_AUTH_SECRET")
if env:
return env.encode("utf-8")
if not hasattr(_secret, "_fallback"):
_secret._fallback = os.urandom(32) # type: ignore[attr-defined]
return _secret._fallback # type: ignore[attr-defined]
def _sign(payload: bytes) -> str:
sig = hmac.new(_secret(), payload, hashlib.sha256).digest()
return _b64url(sig)
@dataclass(frozen=True)
class Session:
user_id: int
expire_at: int
@property
def expired(self) -> bool:
return time.time() >= self.expire_at
def issue_token(user_id: int, *, ttl: int = DEFAULT_TTL) -> str:
"""为 user_id 签发会话 token。"""
if not isinstance(user_id, int) or user_id <= 0:
raise ValueError("user_id must be a positive int")
expire_at = int(time.time()) + ttl
payload = "%d.%d" % (user_id, expire_at)
sig = _sign(payload.encode("ascii"))
return payload + "." + sig
def parse_token(token: str) -> Optional[Session]:
"""校验 token,成功返回 Session,失败(格式/签名/过期)返回 None。"""
if not isinstance(token, str):
return None
parts = token.split(".")
if len(parts) != 3:
return None
uid_s, exp_s, sig = parts
payload = (uid_s + "." + exp_s).encode("ascii")
expected = _sign(payload)
# 恒定时间比较签名
if not hmac.compare_digest(expected, sig):
return None
try:
uid = int(uid_s)
expire_at = int(exp_s)
except ValueError:
return None
if uid <= 0:
return None
sess = Session(user_id=uid, expire_at=expire_at)
if sess.expired:
return None
return sess