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:
+14
-5
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user