feat(auth): 后台登录改为滑动会话

- JWT 记录 iat,exp=min(now+24h, iat+7d)
- 已认证请求自动续签 httpOnly Cookie
- 新增 ACCESS_TOKEN_IDLE_HOURS / ACCESS_TOKEN_MAX_HOURS
This commit is contained in:
yml2213
2026-10-25 14:07:44 +08:00
parent 935be7a613
commit 6fc453017c
9 changed files with 378 additions and 35 deletions
+5
View File
@@ -6,6 +6,11 @@
# 生成方式: python -c "import secrets; print(secrets.token_urlsafe(32))"
JWT_SECRET_KEY=
# 后台登录会话:空闲 24h 过期,绝对上限 7d(活跃请求自动续期,不会突破 7d)
# 旧变量 ACCESS_TOKEN_EXPIRE_HOURS 仍可作为 ACCESS_TOKEN_IDLE_HOURS 的回退值。
ACCESS_TOKEN_IDLE_HOURS=24
ACCESS_TOKEN_MAX_HOURS=168
# 敏感字段落盘加密密钥(生产环境必须设置且长期保持不变!)
# 用于加密账号密码、邮箱密码、Cookie、代理密钥等数据库字段
# 生成方式: python -c "import secrets; print(secrets.token_urlsafe(32))"
+1
View File
@@ -110,6 +110,7 @@ python3 -c 'import secrets; print(secrets.token_urlsafe(32))'
- `FISH_FIN_RECHARGE_*`:鱼翅供应商直充,只走环境变量,不入库、不回前端。
- `MAIL_ROUNDCUBE_URL`:邮件验证码服务;不填则用代码默认 Roundcube 地址。
- `CORS_ORIGINS`、`COOKIE_SECURE`:HTTPS 部署时按实际填写。
- `ACCESS_TOKEN_IDLE_HOURS` / `ACCESS_TOKEN_MAX_HOURS`:后台登录滑动会话,默认 24h 空闲过期、7d 绝对上限;活跃请求自动续签,但不会突破 7d。
应用宝扫码图和支付二维码只经后台授权接口返回。服务器只需要本仓库,不必再部署 `js_reverse`。Worker 任务数据在 `data/yyb-worker-jobs/`。
+3
View File
@@ -39,6 +39,9 @@ services:
- DB_POOL_RECYCLE=${DB_POOL_RECYCLE:-1800}
# JWT 密钥(生产环境务必修改,可用 python -c "import secrets; print(secrets.token_urlsafe(32))" 生成)
- JWT_SECRET_KEY=${JWT_SECRET_KEY:-}
# 后台登录滑动会话:24h 空闲过期,7d 绝对上限。
- ACCESS_TOKEN_IDLE_HOURS=${ACCESS_TOKEN_IDLE_HOURS:-24}
- ACCESS_TOKEN_MAX_HOURS=${ACCESS_TOKEN_MAX_HOURS:-168}
# 敏感字段落盘加密密钥(生产环境务必长期保持不变)
- APP_ENCRYPTION_KEY=${APP_ENCRYPTION_KEY:-}
# 默认管理员账号密码(仅首次启动建库时生效)
+118
View File
@@ -0,0 +1,118 @@
"""后台登录滑动会话:24h 空闲 + 7d 绝对上限。"""
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from jose import jwt
from starlette.responses import Response
from web.backend import security
from web.backend.session import _should_renew, set_session_cookie
NOW = datetime.now(UTC).replace(microsecond=0)
def test_issue_token_uses_24h_idle_window():
issued = security.issue_access_token({"sub": "7"}, now=NOW)
payload = security.decode_access_token(issued.token)
assert payload is not None
assert payload["iat"] == int(NOW.timestamp())
assert issued.expires_at == NOW + timedelta(hours=24)
assert not security.is_session_expired(payload, now=NOW + timedelta(hours=23))
assert security.is_session_expired(payload, now=NOW + timedelta(hours=24))
def test_renew_extends_idle_window_but_keeps_session_start():
issued = security.issue_access_token({"sub": "7"}, now=NOW)
renewed = security.renew_access_token(issued.token, now=NOW + timedelta(hours=23))
assert renewed is not None
assert renewed.issued_at == NOW
assert renewed.expires_at == NOW + timedelta(hours=47)
payload = security.decode_access_token(renewed.token)
assert payload is not None
assert payload["iat"] == int(NOW.timestamp())
def test_renew_never_passes_seven_day_absolute_cap():
# 模拟一个已经连续续期到第 6 天 23:30 的会话(仍有效),
# 下一次续期只能把 exp 顶到 7d,不能推到 7d23h。
near_cap = jwt.encode(
{
"sub": "7",
"iat": NOW,
"exp": NOW + timedelta(days=6, hours=23, minutes=30),
},
security.SECRET_KEY_TYPED,
algorithm=security.ALGORITHM,
)
renewed = security.renew_access_token(
near_cap, now=NOW + timedelta(days=6, hours=23)
)
assert renewed is not None
assert renewed.expires_at == NOW + timedelta(days=7)
payload = security.decode_access_token(renewed.token)
assert payload is not None
assert security.is_session_expired(payload, now=NOW + timedelta(days=7))
# 已经顶到绝对上限后不再续签。
assert (
security.renew_access_token(
renewed.token, now=NOW + timedelta(days=6, hours=23, minutes=30)
)
is None
)
def test_legacy_token_without_iat_anchors_cap_to_original_issue_time():
legacy = jwt.encode(
{
"sub": "7",
"exp": NOW + timedelta(hours=1),
},
security.SECRET_KEY_TYPED,
algorithm=security.ALGORITHM,
)
renewed = security.renew_access_token(legacy, now=NOW)
assert renewed is not None
# 旧 Token 固定 24h,因此签发时间 = exp - 24h。
assert renewed.issued_at == NOW - timedelta(hours=23)
assert renewed.expires_at == NOW + timedelta(hours=24)
def test_set_session_cookie_matches_token_expiry():
response = Response()
expires_at = NOW + timedelta(hours=24)
set_session_cookie(response, "token-value", expires_at, now=NOW)
cookie = response.headers["set-cookie"].lower()
assert "access_token=token-value" in cookie
assert "httponly" in cookie
assert "samesite=lax" in cookie
assert "max-age=86400" in cookie
assert "path=/" in cookie
def test_should_renew_only_authenticated_api_requests():
request = SimpleNamespace(
url=SimpleNamespace(path="/api/accounts"),
state=SimpleNamespace(current_user=object()),
)
assert _should_renew(request, 200)
assert _should_renew(request, 403)
assert not _should_renew(request, 401)
request.url.path = "/api/auth/logout"
assert not _should_renew(request, 200)
request.url.path = "/assets/app.js"
assert not _should_renew(request, 200)
request.url.path = "/api/accounts"
request.state.current_user = None
assert not _should_renew(request, 200)
+14 -5
View File
@@ -8,7 +8,8 @@ from sqlalchemy.orm import Session
from .database import SessionLocal, get_db
from .models import User
from .permissions import get_user_permissions
from .security import decode_access_token
from .security import decode_access_token, is_session_expired
from .session import SESSION_COOKIE_NAME
# auto_error=False: 允许 token 为空(后续从 cookie 读取)
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
@@ -25,13 +26,15 @@ def get_current_user(
headers={"WWW-Authenticate": "Bearer"},
)
# 优先从 Authorization Bearer header 读取,回退到 httpOnly cookie
from_cookie = False
if not token:
token = request.cookies.get("access_token")
token = request.cookies.get(SESSION_COOKIE_NAME)
from_cookie = True
if not token:
raise credentials_exc
try:
payload = decode_access_token(token)
if payload is None:
if payload is None or is_session_expired(payload):
raise credentials_exc
user_id_raw = payload.get("sub")
if user_id_raw is None:
@@ -43,6 +46,10 @@ def get_current_user(
user = db.query(User).filter(User.id == int(user_id)).first()
if user is None or not user.is_active:
raise credentials_exc
# 中间件据此只在「已通过认证的请求」上续签 Cookie;Bearer 客户端不写 Cookie。
request.state.current_user = user
if from_cookie:
request.state.auth_cookie_token = token
return user
@@ -66,11 +73,13 @@ def authenticate_websocket(websocket: WebSocket) -> User | None:
Returns:
User 如果认证成功,None 如果失败。
"""
token = websocket.cookies.get("access_token") or websocket.query_params.get("token")
token = websocket.cookies.get(SESSION_COOKIE_NAME) or websocket.query_params.get(
"token"
)
if not token:
return None
payload = decode_access_token(token)
if not payload or not payload.get("sub"):
if not payload or is_session_expired(payload) or not payload.get("sub"):
return None
db = SessionLocal()
try:
+4
View File
@@ -36,6 +36,7 @@ from .routers import (
yyb,
)
from .schemas import AppInfo
from .session import SessionRenewalMiddleware
from .version import get_app_version
@@ -149,6 +150,9 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
app.add_middleware(SecurityHeadersMiddleware)
# 后台登录滑动续期:24h 空闲过期,7d 绝对上限。
app.add_middleware(SessionRenewalMiddleware)
# 注册路由
app.include_router(auth.router)
app.include_router(users.router)
+6 -22
View File
@@ -2,8 +2,6 @@
from __future__ import annotations
import os
from fastapi import APIRouter, Depends, Request, Response
from sqlalchemy.orm import Session
@@ -21,7 +19,7 @@ from ..schemas import (
SmsCodeResponse,
TokenResponse,
)
from ..security import ACCESS_TOKEN_EXPIRE_HOURS, create_access_token, verify_password
from ..security import issue_access_token, verify_password
from ..services.dc_auth_service import (
APP_ID,
CHALLENGE_PURPOSE,
@@ -37,12 +35,10 @@ from ..services.login_sms_service import (
send_login_sms_code,
verify_login_sms_code,
)
from ..session import clear_session_cookie, set_session_cookie
router = APIRouter(prefix="/api/auth", tags=["认证"])
# Cookie 安全配置(生产环境 HTTPS 部署时设置 COOKIE_SECURE=true)
_COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() == "true"
def _find_user(db: Session, username_value: str) -> User | None:
"""用户名只 trim,不做大小写折叠、不限制字符集。
@@ -75,18 +71,6 @@ def _request_origin(request: Request) -> str:
return str(request.base_url).rstrip("/")
def _set_auth_cookie(response: Response, token: str) -> None:
response.set_cookie(
key="access_token",
value=token,
httponly=True,
secure=_COOKIE_SECURE,
samesite="lax",
max_age=ACCESS_TOKEN_EXPIRE_HOURS * 3600,
path="/",
)
def _issue_session(
db: Session,
response: Response,
@@ -94,8 +78,8 @@ def _issue_session(
method: str,
device_id: str = "",
) -> TokenResponse:
token = create_access_token({"sub": str(user.id), "role": user.role})
_set_auth_cookie(response, token)
issued = issue_access_token({"sub": str(user.id), "role": user.role})
set_session_cookie(response, issued.token, issued.expires_at)
detail = f"method={method}"
if device_id:
@@ -112,7 +96,7 @@ def _issue_session(
db.commit()
return TokenResponse(
access_token=token,
access_token=issued.token,
role=user.role,
username=user.username,
permissions=get_user_permissions(user),
@@ -252,7 +236,7 @@ def logout(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
response.delete_cookie(key="access_token", path="/")
clear_session_cookie(response)
db.add(
AuditLog(
user_id=current_user.id,
+140 -8
View File
@@ -1,8 +1,18 @@
"""安全模块:密码哈希 + JWT"""
"""安全模块:密码哈希 + 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
@@ -20,7 +30,22 @@ if not SECRET_KEY:
assert SECRET_KEY is not None
SECRET_KEY_TYPED: str = SECRET_KEY
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_HOURS = int(os.getenv("ACCESS_TOKEN_EXPIRE_HOURS", "24"))
# 兼容旧部署:只配置了 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:
@@ -37,13 +62,75 @@ def verify_password(plain: str, hashed: str) -> bool:
return False
def create_access_token(
data: dict, expires_hours: int = ACCESS_TOKEN_EXPIRE_HOURS
) -> str:
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()
expire = datetime.now(UTC) + timedelta(hours=expires_hours)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY_TYPED, algorithm=ALGORITHM)
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:
@@ -51,3 +138,48 @@ def decode_access_token(token: str) -> dict | None:
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)
+87
View File
@@ -0,0 +1,87 @@
"""后台登录 Cookie 与滑动续期中间件。
续期只发生在「已经通过 ``get_current_user`` 的请求」上:
- ``get_current_user`` 把当前用户和「本次认证用的 Cookie Token」写进
``request.state``;
- 响应完成后,中间件读取该 Token,把 ``exp`` 推到 ``now + 24h``(不超过 7 天);
- 登录流程、登出、未认证请求、401 响应一律不续期。
"""
from __future__ import annotations
import os
from datetime import UTC, datetime
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from .security import AccessToken, renew_access_token
SESSION_COOKIE_NAME = "access_token"
# 生产环境 HTTPS 部署时设置 COOKIE_SECURE=true
_COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() == "true"
# 登出会删除 Cookie,不能再被中间件补回来。
_NO_RENEW_PATHS = frozenset({"/api/auth/logout"})
def set_session_cookie(
response: Response,
token: str,
expires_at: datetime,
*,
now: datetime | None = None,
) -> None:
"""写入会话 Cookie;有效期跟随 JWT 的绝对过期时刻。"""
current = now or datetime.now(UTC)
max_age = int((expires_at - current).total_seconds())
if max_age <= 0:
return
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=token,
httponly=True,
secure=_COOKIE_SECURE,
samesite="lax",
max_age=max_age,
expires=expires_at,
path="/",
)
def clear_session_cookie(response: Response) -> None:
response.delete_cookie(key=SESSION_COOKIE_NAME, path="/")
def _should_renew(request: Request, status_code: int) -> bool:
"""只对「已认证的 API 请求 + 非 401 + 非登出」续期。"""
path = request.url.path.rstrip("/") or "/"
if status_code == 401:
return False
if not path.startswith("/api/"):
return False
if path in _NO_RENEW_PATHS:
return False
return getattr(request.state, "current_user", None) is not None
class SessionRenewalMiddleware(BaseHTTPMiddleware):
"""滑动续期:活跃请求自动延长 24h,但永不突破 7 天绝对上限。"""
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
response = await call_next(request)
if not _should_renew(request, response.status_code):
return response
token = getattr(request.state, "auth_cookie_token", None)
if not token:
return response
renewed: AccessToken | None = renew_access_token(token)
if renewed is None:
return response
set_session_cookie(response, renewed.token, renewed.expires_at)
return response