diff --git a/.env.example b/.env.example index 78bbd5c..93cd0d5 100644 --- a/.env.example +++ b/.env.example @@ -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))" diff --git a/README.md b/README.md index f3b68ad..09bebb3 100644 --- a/README.md +++ b/README.md @@ -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/`。 diff --git a/docker-compose.yml b/docker-compose.yml index b6ade33..1c951ee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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:-} # 默认管理员账号密码(仅首次启动建库时生效) diff --git a/tests/test_auth_sliding_session.py b/tests/test_auth_sliding_session.py new file mode 100644 index 0000000..3b26540 --- /dev/null +++ b/tests/test_auth_sliding_session.py @@ -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) diff --git a/web/backend/deps.py b/web/backend/deps.py index f976025..5e68aae 100644 --- a/web/backend/deps.py +++ b/web/backend/deps.py @@ -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: diff --git a/web/backend/main.py b/web/backend/main.py index 6b197ef..dbdff3e 100644 --- a/web/backend/main.py +++ b/web/backend/main.py @@ -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) diff --git a/web/backend/routers/auth.py b/web/backend/routers/auth.py index 91082ff..8327909 100644 --- a/web/backend/routers/auth.py +++ b/web/backend/routers/auth.py @@ -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, diff --git a/web/backend/security.py b/web/backend/security.py index af17e15..2452f1f 100644 --- a/web/backend/security.py +++ b/web/backend/security.py @@ -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) diff --git a/web/backend/session.py b/web/backend/session.py new file mode 100644 index 0000000..9216276 --- /dev/null +++ b/web/backend/session.py @@ -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