98 lines
3.0 KiB
Python
98 lines
3.0 KiB
Python
# -*- 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
|