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))" # 生成方式: python -c "import secrets; print(secrets.token_urlsafe(32))"
JWT_SECRET_KEY= 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、代理密钥等数据库字段 # 用于加密账号密码、邮箱密码、Cookie、代理密钥等数据库字段
# 生成方式: python -c "import secrets; print(secrets.token_urlsafe(32))" # 生成方式: 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_*`:鱼翅供应商直充,只走环境变量,不入库、不回前端。 - `FISH_FIN_RECHARGE_*`:鱼翅供应商直充,只走环境变量,不入库、不回前端。
- `MAIL_ROUNDCUBE_URL`:邮件验证码服务;不填则用代码默认 Roundcube 地址。 - `MAIL_ROUNDCUBE_URL`:邮件验证码服务;不填则用代码默认 Roundcube 地址。
- `CORS_ORIGINS`、`COOKIE_SECURE`:HTTPS 部署时按实际填写。 - `CORS_ORIGINS`、`COOKIE_SECURE`:HTTPS 部署时按实际填写。
- `ACCESS_TOKEN_IDLE_HOURS` / `ACCESS_TOKEN_MAX_HOURS`:后台登录滑动会话,默认 24h 空闲过期、7d 绝对上限;活跃请求自动续签,但不会突破 7d。
应用宝扫码图和支付二维码只经后台授权接口返回。服务器只需要本仓库,不必再部署 `js_reverse`。Worker 任务数据在 `data/yyb-worker-jobs/`。 应用宝扫码图和支付二维码只经后台授权接口返回。服务器只需要本仓库,不必再部署 `js_reverse`。Worker 任务数据在 `data/yyb-worker-jobs/`。
+3
View File
@@ -39,6 +39,9 @@ services:
- DB_POOL_RECYCLE=${DB_POOL_RECYCLE:-1800} - DB_POOL_RECYCLE=${DB_POOL_RECYCLE:-1800}
# JWT 密钥(生产环境务必修改,可用 python -c "import secrets; print(secrets.token_urlsafe(32))" 生成) # JWT 密钥(生产环境务必修改,可用 python -c "import secrets; print(secrets.token_urlsafe(32))" 生成)
- JWT_SECRET_KEY=${JWT_SECRET_KEY:-} - 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:-} - 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 .database import SessionLocal, get_db
from .models import User from .models import User
from .permissions import get_user_permissions 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 读取) # auto_error=False: 允许 token 为空(后续从 cookie 读取)
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False) oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
@@ -25,13 +26,15 @@ def get_current_user(
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
# 优先从 Authorization Bearer header 读取,回退到 httpOnly cookie # 优先从 Authorization Bearer header 读取,回退到 httpOnly cookie
from_cookie = False
if not token: if not token:
token = request.cookies.get("access_token") token = request.cookies.get(SESSION_COOKIE_NAME)
from_cookie = True
if not token: if not token:
raise credentials_exc raise credentials_exc
try: try:
payload = decode_access_token(token) payload = decode_access_token(token)
if payload is None: if payload is None or is_session_expired(payload):
raise credentials_exc raise credentials_exc
user_id_raw = payload.get("sub") user_id_raw = payload.get("sub")
if user_id_raw is None: 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() user = db.query(User).filter(User.id == int(user_id)).first()
if user is None or not user.is_active: if user is None or not user.is_active:
raise credentials_exc raise credentials_exc
# 中间件据此只在「已通过认证的请求」上续签 Cookie;Bearer 客户端不写 Cookie。
request.state.current_user = user
if from_cookie:
request.state.auth_cookie_token = token
return user return user
@@ -66,11 +73,13 @@ def authenticate_websocket(websocket: WebSocket) -> User | None:
Returns: Returns:
User 如果认证成功,None 如果失败。 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: if not token:
return None return None
payload = decode_access_token(token) 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 return None
db = SessionLocal() db = SessionLocal()
try: try:
+4
View File
@@ -36,6 +36,7 @@ from .routers import (
yyb, yyb,
) )
from .schemas import AppInfo from .schemas import AppInfo
from .session import SessionRenewalMiddleware
from .version import get_app_version from .version import get_app_version
@@ -149,6 +150,9 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
app.add_middleware(SecurityHeadersMiddleware) app.add_middleware(SecurityHeadersMiddleware)
# 后台登录滑动续期:24h 空闲过期,7d 绝对上限。
app.add_middleware(SessionRenewalMiddleware)
# 注册路由 # 注册路由
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(users.router) app.include_router(users.router)
+6 -22
View File
@@ -2,8 +2,6 @@
from __future__ import annotations from __future__ import annotations
import os
from fastapi import APIRouter, Depends, Request, Response from fastapi import APIRouter, Depends, Request, Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -21,7 +19,7 @@ from ..schemas import (
SmsCodeResponse, SmsCodeResponse,
TokenResponse, 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 ( from ..services.dc_auth_service import (
APP_ID, APP_ID,
CHALLENGE_PURPOSE, CHALLENGE_PURPOSE,
@@ -37,12 +35,10 @@ from ..services.login_sms_service import (
send_login_sms_code, send_login_sms_code,
verify_login_sms_code, verify_login_sms_code,
) )
from ..session import clear_session_cookie, set_session_cookie
router = APIRouter(prefix="/api/auth", tags=["认证"]) 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: def _find_user(db: Session, username_value: str) -> User | None:
"""用户名只 trim,不做大小写折叠、不限制字符集。 """用户名只 trim,不做大小写折叠、不限制字符集。
@@ -75,18 +71,6 @@ def _request_origin(request: Request) -> str:
return str(request.base_url).rstrip("/") 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( def _issue_session(
db: Session, db: Session,
response: Response, response: Response,
@@ -94,8 +78,8 @@ def _issue_session(
method: str, method: str,
device_id: str = "", device_id: str = "",
) -> TokenResponse: ) -> TokenResponse:
token = create_access_token({"sub": str(user.id), "role": user.role}) issued = issue_access_token({"sub": str(user.id), "role": user.role})
_set_auth_cookie(response, token) set_session_cookie(response, issued.token, issued.expires_at)
detail = f"method={method}" detail = f"method={method}"
if device_id: if device_id:
@@ -112,7 +96,7 @@ def _issue_session(
db.commit() db.commit()
return TokenResponse( return TokenResponse(
access_token=token, access_token=issued.token,
role=user.role, role=user.role,
username=user.username, username=user.username,
permissions=get_user_permissions(user), permissions=get_user_permissions(user),
@@ -252,7 +236,7 @@ def logout(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
response.delete_cookie(key="access_token", path="/") clear_session_cookie(response)
db.add( db.add(
AuditLog( AuditLog(
user_id=current_user.id, 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 os
import secrets import secrets
import warnings import warnings
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
import bcrypt import bcrypt
@@ -20,7 +30,22 @@ if not SECRET_KEY:
assert SECRET_KEY is not None assert SECRET_KEY is not None
SECRET_KEY_TYPED: str = SECRET_KEY SECRET_KEY_TYPED: str = SECRET_KEY
ALGORITHM = "HS256" 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: def hash_password(password: str) -> str:
@@ -37,13 +62,75 @@ def verify_password(plain: str, hashed: str) -> bool:
return False return False
def create_access_token( def _utcnow() -> datetime:
data: dict, expires_hours: int = ACCESS_TOKEN_EXPIRE_HOURS return datetime.now(UTC).replace(microsecond=0)
) -> str:
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 = data.copy()
expire = datetime.now(UTC) + timedelta(hours=expires_hours) to_encode.update({"iat": started, "exp": expire})
to_encode.update({"exp": expire}) token = jwt.encode(to_encode, SECRET_KEY_TYPED, algorithm=ALGORITHM)
return 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: 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]) return jwt.decode(token, SECRET_KEY_TYPED, algorithms=[ALGORITHM])
except JWTError: except JWTError:
return None 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