"""安全模块:密码哈希 + JWT。 后台登录采用「滑动会话」: - ``ACCESS_TOKEN_IDLE_HOURS``:空闲多久失效,默认 24 小时; - ``ACCESS_TOKEN_MAX_HOURS``:从首次登录起算的绝对上限,默认 7 天。 每个带有效会话的 API 请求都会续签 Cookie,但 ``iat`` 保持不变, 因此活跃用户最多也只能维持 7 天,之后必须重新登录。 """ import os import secrets import warnings from dataclasses import dataclass from datetime import UTC, datetime, timedelta import bcrypt from jose import JWTError, jwt # JWT 密钥:生产环境必须通过环境变量 JWT_SECRET_KEY 设置 SECRET_KEY = os.getenv("JWT_SECRET_KEY") if not SECRET_KEY: warnings.warn( "未设置 JWT_SECRET_KEY 环境变量,使用临时随机密钥(重启后所有 Token 将失效)。" "生产环境请务必设置 JWT_SECRET_KEY 环境变量。", stacklevel=2, ) SECRET_KEY = secrets.token_urlsafe(32) assert SECRET_KEY is not None SECRET_KEY_TYPED: str = SECRET_KEY ALGORITHM = "HS256" # 兼容旧部署:只配置了 ACCESS_TOKEN_EXPIRE_HOURS 时,把它当作空闲时长。 ACCESS_TOKEN_IDLE_HOURS = int( os.getenv("ACCESS_TOKEN_IDLE_HOURS", os.getenv("ACCESS_TOKEN_EXPIRE_HOURS", "24")) ) ACCESS_TOKEN_MAX_HOURS = int(os.getenv("ACCESS_TOKEN_MAX_HOURS", str(7 * 24))) ACCESS_TOKEN_EXPIRE_HOURS = ACCESS_TOKEN_IDLE_HOURS @dataclass(frozen=True) class AccessToken: """一次签发结果:Token 本体 + 会话起点 + 过期时刻。""" token: str issued_at: datetime expires_at: datetime def hash_password(password: str) -> str: pwd_bytes = password.encode("utf-8") # bcrypt 限制72字节,截断 pwd_bytes = pwd_bytes[:72] return bcrypt.hashpw(pwd_bytes, bcrypt.gensalt()).decode("utf-8") def verify_password(plain: str, hashed: str) -> bool: try: return bcrypt.checkpw(plain.encode("utf-8")[:72], hashed.encode("utf-8")) except Exception: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底 return False def _utcnow() -> datetime: return datetime.now(UTC).replace(microsecond=0) def _as_utc(value: object) -> datetime | None: """把 JWT 里的时间字段(datetime / 时间戳)统一转成 UTC datetime。""" if isinstance(value, datetime): return ( value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) ) if isinstance(value, (int, float)): try: return datetime.fromtimestamp(value, UTC) except (OverflowError, OSError, ValueError): return None if isinstance(value, str): try: return datetime.fromtimestamp(float(value), UTC) except (OverflowError, OSError, ValueError): return None return None def _session_start(payload: dict) -> datetime | None: """会话起点:新 Token 用 ``iat``,旧 Token 用 ``exp - 24h`` 反推。 旧 Token 没有 ``iat``。上线后第一次续签必须把绝对上限锚定到真实登录时刻, 不能从「第一次续签」重新算 7 天,否则老会话会被额外延长。 """ issued_at = _as_utc(payload.get("iat")) if issued_at is not None: return issued_at expires_at = _as_utc(payload.get("exp")) if expires_at is None: return None return expires_at - timedelta(hours=ACCESS_TOKEN_IDLE_HOURS) def _absolute_expiry(issued_at: datetime) -> datetime: return issued_at + timedelta(hours=ACCESS_TOKEN_MAX_HOURS) def issue_access_token( data: dict, *, issued_at: datetime | None = None, now: datetime | None = None, ) -> AccessToken: """签发新会话 Token:``exp = min(now + 空闲时长, 会话起点 + 7 天)``。""" current = _utcnow() if now is None else now.replace(microsecond=0) started = current if issued_at is None else issued_at.replace(microsecond=0) expire = min( current + timedelta(hours=ACCESS_TOKEN_IDLE_HOURS), _absolute_expiry(started), ) to_encode = data.copy() to_encode.update({"iat": started, "exp": expire}) token = jwt.encode(to_encode, SECRET_KEY_TYPED, algorithm=ALGORITHM) return AccessToken(token=token, issued_at=started, expires_at=expire) def create_access_token( data: dict, *, issued_at: datetime | None = None, now: datetime | None = None, ) -> str: """兼容旧调用:只返回 Token 字符串。""" return issue_access_token(data, issued_at=issued_at, now=now).token def decode_access_token(token: str) -> dict | None: try: return jwt.decode(token, SECRET_KEY_TYPED, algorithms=[ALGORITHM]) except JWTError: return None def is_session_expired(payload: dict, *, now: datetime | None = None) -> bool: """同时检查空闲过期与 7 天绝对上限。""" current = now or _utcnow() expires_at = _as_utc(payload.get("exp")) if expires_at is None or current >= expires_at: return True issued_at = _session_start(payload) return issued_at is not None and current >= _absolute_expiry(issued_at) def renew_access_token( token: str, *, now: datetime | None = None ) -> AccessToken | None: """续签有效会话;已到 7 天绝对上限或 Token 本身无效时返回 ``None``。 续签保留原 ``iat``,只把 ``exp`` 推到 ``now + 24h``,且不超过绝对上限。 """ payload = decode_access_token(token) if payload is None: return None current = (_utcnow() if now is None else now).replace(microsecond=0) if is_session_expired(payload, now=current): return None issued_at = _session_start(payload) if issued_at is None: return None absolute_expire = _absolute_expiry(issued_at) if current >= absolute_expire: return None old_expire = _as_utc(payload.get("exp")) new_expire = min( current + timedelta(hours=ACCESS_TOKEN_IDLE_HOURS), absolute_expire ) if old_expire is not None and new_expire <= old_expire: # 已顶到绝对上限,或本次请求没有带来有意义的延长:不重发 Cookie。 return None claims = payload.copy() claims.pop("exp", None) claims.update({"iat": issued_at, "exp": new_expire}) renewed = jwt.encode(claims, SECRET_KEY_TYPED, algorithm=ALGORITHM) return AccessToken(token=renewed, issued_at=issued_at, expires_at=new_expire)