iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0

Day 15-16 完成了登入與用戶隔離,但留下兩個問題:

  1. access token 只有 15 分鐘:過期後前端每個請求都是 401,使用者只能重新輸入帳密
  2. 登出沒有作用:Day 15 實測過,呼叫 /logout 後,同一個 token 打 /me 仍然回 200

這兩個問題互相拉扯:token 效期越短越安全,但使用者越常被踢出去;想讓登出生效,伺服器就得記住「哪些 token 還算數」,而這又違背了 JWT 不用查表的初衷。

今天用兩個機制同時解決:

  • 刷新令牌(refresh token):效期 7 天,只用來換新的 access token
  • 會話(session):伺服器記錄每一次登入,登出、閒置過久時撤銷

這一天在系列中的位置

第 2 週:檢測優化與生產系統

  • Day 15 → 資料庫模型、密碼雜湊、註冊與登入
  • Day 16 → 需要登入的檢測端點、任務持久化、用戶隔離、登入版前端
  • Day 17 → 刷新令牌與會話管理 ← 今天(第 2 週最後一天)

今日目標

今天要完成:

  1. 兩種令牌 src/auth.py:access token 15 分鐘、refresh token 7 天,彼此不能混用
  2. 會話管理器 src/session_manager.py:登記、驗證、輪換、撤銷、閒置超時
  3. 認證端點 src/api_auth.py:登入回傳 refresh token;新增 /refresh、/logout-all、/sessions;/logout 真正撤銷
  4. 前端自動刷新 frontend/apiClient.js:遇到 401 自動換 token 並重試,使用者無感
  5. 測試 tests/test_day17_refresh_token.py:10 個測試,其中 3 個走真實的 API

問題背景

兩種令牌的分工

access token   15 分鐘   每個 API 請求都帶著它          外洩時損害小(很快過期)
refresh token  7 天      只在 access token 過期時使用   只送到 /refresh,暴露機會少

access token 過期時,前端拿 refresh token 呼叫 /refresh 換一組新的,使用者完全感覺不到。只有 refresh token 也失效(7 天沒用、被登出、閒置超時)時,才需要重新登入。

刷新令牌輪換(rotation)

如果 refresh token 可以重複使用,它一旦外洩,攻擊者就有 7 天可以不斷換新的 access token。輪換的做法是:每次 /refresh 都發一組新的 refresh token,舊的立刻作廢。攻擊者拿到的舊 refresh token 只要被合法使用者先用過,就換不到任何東西。

為什麼需要伺服器端的會話

「作廢」這件事,純 JWT 做不到:簽章有效、還沒到期的 token,伺服器沒有理由拒絕。所以要在伺服器記一張表:

user_id → [ Session(access_token, refresh_token, created_at, last_activity, ip, user_agent), ... ]

每個請求除了驗簽章,還要確認 token 還在這張表裡。登出就是把它從表裡刪掉。


檢查時發現的問題

這一天的程式碼原本就存在(create_refresh_token、SessionManager 都寫好了,也有 7 個單元測試),但實測後發現它完全沒有接到 API,而且本身有幾個 bug:

問題 影響
登入不回傳 refresh token,也沒有 /refresh 端點 refresh token 根本用不到
get_current_user 不檢查會話 登出、閒置超時都不會讓 token 失效
verify_token() 不檢查令牌類型 7 天效期的 refresh token 可以直接當 access token 呼叫任何 API
refresh_access_token() 發新 refresh token,但舊的仍有效 輪換沒有作用
get_session() 先更新活動時間、再判斷是否閒置 永遠不會判定為閒置
cleanup_expired_sessions() 以 access token 的 15 分鐘判斷過期 refresh token 還有 7 天,會話卻在 15 分鐘後被清掉;回傳值也不是清掉的數量
JWT 只有 sub 與 exp 同一秒內登入兩次,拿到一模一樣的 token,會話無法區分

第三點是最嚴重的:縮短 access token 效期的所有好處,都被「refresh token 也能當 access token 用」抵銷了。下面逐一修正。


實現方法

POST /login    → 簽發 access + refresh → session_manager.create_session()
任何受保護請求 → verify_token(拒絕 refresh token)→ session_manager.get_session()
                  └─ 不存在或閒置超時 → 401「會話已失效,請重新登入」
POST /refresh  → verify_refresh_token → get_session_by_refresh → rotate_session(舊的作廢)
POST /logout   → revoke_session(這台裝置)
POST /logout-all → revoke_all_sessions(所有裝置)
GET  /sessions → 列出登入中的裝置
  • 輸入:登入帳密、access token、refresh token
  • 輸出:新令牌組、會話清單、撤銷結果
  • 檔案:src/auth.py、src/session_manager.py、src/api_auth.py、frontend/apiClient.js、frontend/App.jsx(修改);tests/test_day17_refresh_token.py(修改)

專案結構變化:

srs-review-agent/
├── src/
│   ├── auth.py                   ← 修改:令牌 type 與 jti、verify_token 拒絕 refresh token
│   ├── session_manager.py        ← 修改:輪換、閒置檢查、清理邏輯
│   └── api_auth.py               ← 修改:登入建立會話、/refresh、/logout-all、/sessions
├── frontend/
│   ├── apiClient.js              ← 修改:tokenStore、authRequest 自動刷新
│   └── App.jsx                   ← 修改:保存 refresh token、會話失效時回到登入畫面
└── tests/
    └── test_day17_refresh_token.py ← 修改:10 個測試

今天不需要新的套件。可調整的環境變數:

變數 預設 說明
ACCESS_TOKEN_EXPIRE_MINUTES 15 access token 效期(分鐘);測試過期情境時可設為 1
SESSION_INACTIVITY_MINUTES 30 閒置超過這個時間自動登出

代碼示例

1. 令牌:區分類型、每個都唯一

修改 src/auth.py:

import uuid

ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "15"))
REFRESH_TOKEN_EXPIRE_DAYS = 7


def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
    to_encode = data.copy()
    expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
    # type:與刷新令牌區分;jti:每個令牌唯一(同一秒登入兩次也不會得到相同令牌)
    to_encode.update({"exp": expire, "type": "access", "jti": uuid.uuid4().hex})
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)


def create_refresh_token(data: dict) -> str:
    to_encode = data.copy()
    expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
    to_encode.update({"exp": expire, "type": "refresh", "jti": uuid.uuid4().hex})
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

verify_token() 加上一行檢查:

payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
# 刷新令牌效期 7 天,不能拿來當訪問令牌呼叫 API
if payload.get("type") == "refresh":
    return None

jti(JWT ID)的必要性容易被忽略:JWT 的內容只有 sub 和 exp,而 exp 精確到秒。同一個用戶在同一秒內登入兩次(例如同時開兩個分頁),拿到的 token 會完全相同,會話表就分不出這是兩台裝置。

2. 會話管理器

修改 src/session_manager.py。會話本身是一個 dataclass:

@dataclass
class Session:
    user_id: str
    access_token: str
    refresh_token: str
    created_at: datetime = field(default_factory=datetime.utcnow)     # 目前這組令牌的簽發時間
    last_activity: datetime = field(default_factory=datetime.utcnow)
    ip_address: str = ""
    user_agent: str = ""

    def is_inactive(self, timeout_minutes: int = 30) -> bool:
        elapsed = (datetime.utcnow() - self.last_activity).total_seconds() / 60
        return elapsed > timeout_minutes

    def rotate(self, access_token: str, refresh_token: str):
        """換上新的一組令牌(刷新令牌輪換)"""
        self.access_token = access_token
        self.refresh_token = refresh_token
        self.created_at = datetime.utcnow()
        self.update_activity()

SessionManager 用 user_id → [Session, ...] 保存會話,所有操作都在 threading.Lock 內進行(FastAPI 會在多個執行緒處理請求)。驗證 access token 時,先判斷閒置、再更新活動時間:

def get_session(self, user_id: str, access_token: str) -> Optional[Session]:
    with self.lock:
        for session in self.sessions.get(user_id, []):
            if session.access_token == access_token:
                if session.is_inactive(self.inactivity_minutes):
                    self.sessions[user_id].remove(session)   # 閒置過久:自動登出
                    return None
                session.update_activity()
                return session
    return None

原本的順序是反過來的:先 update_activity() 再檢查,於是 last_activity 永遠是「剛剛」,閒置超時永遠不會觸發。

輪換必須是原子操作:

def rotate_session(self, user_id: str, old_refresh_token: str,
                   new_access_token: str, new_refresh_token: str) -> bool:
    with self.lock:
        for session in self.sessions.get(user_id, []):
            if session.refresh_token == old_refresh_token:
                session.rotate(new_access_token, new_refresh_token)
                return True
    return False

「找到舊 refresh token」與「換成新的」在同一把鎖內完成。兩個請求同時拿同一個舊 refresh token 來換,只有第一個會找到它,第二個會得到 False。

清理過期會話時,判斷標準改成 refresh token 過期或閒置超時:

def cleanup_expired_sessions(self) -> int:
    """清理過期的會話,返回被清理的數量"""
    count = 0
    with self.lock:
        for user_id in list(self.sessions.keys()):
            before = len(self.sessions[user_id])
            self.sessions[user_id] = [
                s for s in self.sessions[user_id]
                if not s.is_refresh_expired() and not s.is_inactive(self.inactivity_minutes)
            ]
            count += before - len(self.sessions[user_id])
            if not self.sessions[user_id]:
                del self.sessions[user_id]
    return count

access token 過期(15 分鐘)不代表會話結束,使用者還能用 refresh token 換新的,所以不能以它為清理標準。create_session、revoke_session、revoke_all_sessions、get_user_sessions 的完整代碼見 src/session_manager.py。

全域實例:

session_manager = SessionManager(
    max_sessions_per_user=5,
    inactivity_minutes=int(os.getenv("SESSION_INACTIVITY_MINUTES", "30")),
)

每個用戶最多 5 個會話,超過時移除最舊的一個。

3. 接上 API

修改 src/api_auth.py。登入時簽發兩個令牌並登記會話:

@router.post("/login", response_model=LoginResponse)
async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends(),
                db: Session = Depends(get_db)):
    user = authenticate_user(db, form_data.username, form_data.password)
    if not user:
        raise HTTPException(status_code=401, detail="郵箱或密碼錯誤",
                            headers={"WWW-Authenticate": "Bearer"})

    access_token = create_access_token(
        data={"sub": user.id}, expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
    refresh_token = create_refresh_token({"sub": user.id})

    session_manager.create_session(
        user_id=user.id, access_token=access_token, refresh_token=refresh_token,
        ip_address=request.client.host if request.client else "",
        user_agent=request.headers.get("user-agent", ""),
    )
    return {"access_token": access_token, "refresh_token": refresh_token,
            "token_type": "bearer", "user": UserResponse.model_validate(user)}

get_current_user 在驗完簽章後多一道檢查:

# 令牌簽章有效還不夠,會話也必須存在(登出、閒置超時、伺服器重啟後即失效)
if session_manager.get_session(user_id, token) is None:
    raise HTTPException(status_code=401, detail="會話已失效,請重新登入",
                        headers={"WWW-Authenticate": "Bearer"})

因為 Day 16 的 /api/v1/detect、/jobs 等端點都依賴 get_current_user,這一行讓它們全部自動受到會話管理保護。

刷新端點:

@router.post("/refresh", response_model=TokenPair)
async def refresh(body: RefreshRequest):
    payload = verify_refresh_token(body.refresh_token)
    if payload is None:
        raise HTTPException(status_code=401, detail="刷新令牌無效或已過期")

    user_id = payload["user_id"]
    if session_manager.get_session_by_refresh(user_id, body.refresh_token) is None:
        raise HTTPException(status_code=401, detail="會話已失效,請重新登入")

    new_access = create_access_token(
        {"sub": user_id}, expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
    new_refresh = create_refresh_token({"sub": user_id})

    if not session_manager.rotate_session(user_id, body.refresh_token, new_access, new_refresh):
        raise HTTPException(status_code=401, detail="會話已失效,請重新登入")

    return {"access_token": new_access, "refresh_token": new_refresh, "token_type": "bearer"}

三道檢查依序是:refresh token 的簽章與效期(verify_refresh_token 會拒絕 type 不是 refresh 的令牌)、它屬於一個還存在的會話、輪換成功。輪換後,舊的 access token 也跟著失效,因為會話裡記的已經是新的那組。

登出改成真正撤銷:

@router.post("/logout")
async def logout(token: str = Depends(oauth2_scheme),
                 current_user: User = Depends(get_current_user)):
    session_manager.revoke_session(current_user.id, token)
    return {"message": "已登出"}

/logout-all(revoke_all_sessions)與 /sessions(get_user_sessions)的寫法相同,完整代碼見 src/api_auth.py。

4. 前端:自動刷新

修改 frontend/apiClient.js。令牌存在 localStorage,重新整理頁面後仍維持登入:

export const tokenStore = {
  get access() { return localStorage.getItem('token') },
  get refresh() { return localStorage.getItem('refresh_token') },
  set(access, refresh) {
    localStorage.setItem('token', access)
    if (refresh) localStorage.setItem('refresh_token', refresh)
  },
  clear() {
    localStorage.removeItem('token')
    localStorage.removeItem('refresh_token')
  },
}

刷新請求同一時間只送一個:

let refreshing = null

function refreshTokens() {
  if (!refreshing) {
    refreshing = request('/auth/refresh', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ refresh_token: tokenStore.refresh }),
    })
      .then((data) => {
        tokenStore.set(data.access_token, data.refresh_token)
        return data.access_token
      })
      .finally(() => { refreshing = null })
  }
  return refreshing
}

所有需要登入的請求都經過 authRequest():

export async function authRequest(path, options = {}) {
  const withToken = (token) => ({
    ...options,
    headers: { ...(options.headers || {}), Authorization: `Bearer ${token}` },
  })
  const usedToken = tokenStore.access
  try {
    return await request(path, withToken(usedToken))
  } catch (err) {
    if (err.status !== 401 || !tokenStore.refresh) throw err
  }

  // 等到 401 回來時,別的請求可能已經刷新過了:直接用新令牌重試,不要再刷新一次
  if (tokenStore.access && tokenStore.access !== usedToken) {
    return request(path, withToken(tokenStore.access))
  }

  let newToken
  try {
    newToken = await refreshTokens()
  } catch (err) {
    tokenStore.clear()
    onSessionExpired()          // App.jsx 註冊的回呼:切回登入畫面
    throw new Error('登入已過期,請重新登入')
  }
  return request(path, withToken(newToken))
}

中間那段「別的請求已經刷新過了」的判斷,是端對端測試抓到競爭條件後才補上的,下一節會看到實際的請求紀錄。

frontend/App.jsx 在登入時呼叫 tokenStore.set(data.access_token, data.refresh_token),在初始化時用 setOnSessionExpired() 註冊「回到登入畫面」;/auth/me、/auth/jobs、/auth/logout 都改用 authRequest()。


驗證結果

測試

cd srs-review-agent
python3 -m pytest tests/test_day17_refresh_token.py -v
tests/test_day17_refresh_token.py::test_refresh_token_creation PASSED    [ 10%]
tests/test_day17_refresh_token.py::test_refresh_access_token PASSED      [ 20%]
tests/test_day17_refresh_token.py::test_session_manager_creation PASSED  [ 30%]
tests/test_day17_refresh_token.py::test_concurrent_sessions PASSED       [ 40%]
tests/test_day17_refresh_token.py::test_session_expiration PASSED        [ 50%]
tests/test_day17_refresh_token.py::test_session_cleanup PASSED           [ 60%]
tests/test_day17_refresh_token.py::test_session_stats PASSED             [ 70%]
tests/test_day17_refresh_token.py::test_api_refresh_rotation PASSED      [ 80%]
tests/test_day17_refresh_token.py::test_api_logout_revokes PASSED        [ 90%]
tests/test_day17_refresh_token.py::test_api_inactivity_logout PASSED     [100%]

============================== 10 passed in 0.35s ==============================
  • test_session_cleanup:access token 過期但仍在活動中的會話保留,閒置 1 小時的會話被清掉,回傳值為 1
  • test_api_refresh_rotation:refresh token 打 /me 回 401;刷新後新 token 可用,舊 access token 與舊 refresh token 都回 401
  • test_api_logout_revokes:裝置 A 登出後 A 的 token 失效、裝置 B 不受影響;/logout-all 撤銷剩下的 1 個會話
  • test_api_inactivity_logout:把最後活動時間往前調 31 分鐘,訪問與刷新都被拒絕

手動測試

登入回應多了 refresh_token:

curl -X POST http://localhost:8000/api/v1/auth/login -d "username=alice@example.com&password=SecurePass123"
# {"access_token": "...", "refresh_token": "...", "token_type": "bearer", "user": {...}}
請求 結果
用 refresh token 呼叫 /api/v1/auth/me 401「無效的認證令牌」
POST /api/v1/auth/refresh 200,回傳新的 access_token、refresh_token
再用同一個舊 refresh token 刷新 401「會話已失效,請重新登入」
用刷新前的舊 access token 呼叫 /me 401「會話已失效,請重新登入」
刷新令牌亂填 garbage 401「刷新令牌無效或已過期」
POST /api/v1/auth/logout 後呼叫 /me 401「會話已失效,請重新登入」(Day 15 時是 200)
另一台裝置的 token 呼叫 /me 200,不受影響
POST /api/v1/auth/logout-all {"message": "已在所有裝置登出", "revoked": 1}

GET /api/v1/auth/sessions 列出登入中的裝置:

{"sessions": [
  {"created_at": "2026-09-29T06:30:38.982092", "last_activity": "2026-09-29T06:30:38.991226",
   "ip_address": "testclient", "user_agent": "curl/8.7.1"},
  {"created_at": "2026-09-29T06:30:38.990418", "last_activity": "2026-09-29T06:30:38.990418",
   "ip_address": "testclient", "user_agent": "curl/8.7.1"}
]}

回應只包含時間、IP 與 User-Agent,不會把令牌本身回傳出來。

瀏覽器端對端測試

把後端的 access token 效期縮到 1 分鐘,用 Playwright 驅動 headless Chromium:

ACCESS_TOKEN_EXPIRE_MINUTES=1 uvicorn src.api_main:app --port 8000

註冊、登入、提交一次檢測後,等 65 秒讓 access token 過期,再點「檢測結果」:

401 GET  /api/v1/auth/jobs       ← access token 過期
200 POST /api/v1/auth/refresh    ← authRequest 自動刷新
200 GET  /api/v1/auth/jobs       ← 用新 token 重試成功

畫面正常顯示任務列表,使用者完全不需要重新登入。登出後,用舊 token 直接呼叫 /me 得到 401。

抓到的競爭條件

再測一個情境:access token 過期後重新整理頁面。開發模式下 React 的 StrictMode 會把初始化的 useEffect 執行兩次,等於同時發出兩個 /me。第一版 authRequest() 的紀錄是:

401 GET  /api/v1/auth/me
200 POST /api/v1/auth/refresh    ← 請求 A 刷新
401 GET  /api/v1/auth/me
200 POST /api/v1/auth/refresh    ← 請求 B 又刷新一次,把 A 剛拿到的 token 輪換掉
401 GET  /api/v1/auth/me         ← A 用自己拿到的 token 重試,已經失效
200 GET  /api/v1/auth/me
401 GET  /api/v1/auth/jobs       ← 任務列表載入失敗,畫面上是空的

「同時只送一個刷新請求」的機制沒有擋住,因為 B 的 401 回來時,A 的刷新已經結束了,refreshing 又是 null。修正方式是記下這次請求用的是哪個 token:401 回來時如果 tokenStore.access 已經換了,代表別人刷新過,直接用新 token 重試。修正後:

401 GET  /api/v1/auth/me
200 POST /api/v1/auth/refresh    ← 只刷新一次
401 GET  /api/v1/auth/me
200 GET  /api/v1/auth/me         ← B 發現 token 已更新,直接重試
200 GET  /api/v1/auth/me
200 GET  /api/v1/auth/jobs
200 GET  /api/v1/auth/jobs

這種問題單元測試很難發現,因為它需要「兩個請求交錯」加上「令牌剛好過期」同時發生。輪換讓 refresh token 更安全,代價就是前端必須小心處理並行請求。


權衡與限制

  • 會話存在記憶體:伺服器重啟後所有人都要重新登入;多個 uvicorn worker 之間不共享會話,使用者的請求落到另一個 worker 就會 401。多 worker 部署時需要把會話移到 Redis 或資料庫
  • 每個請求都要查會話表:這讓 JWT 失去了「不用查表」的優勢。換來的是登出、閒置超時真正有效。在這個系統裡,能撤銷比省一次記憶體查詢更重要
  • 令牌存在 localStorage:頁面上若有 XSS 漏洞,惡意腳本可以讀走令牌。更安全的做法是把 refresh token 放在 HttpOnly cookie,JavaScript 讀不到,但需要另外處理 CSRF
  • 沒有偵測 refresh token 重用:舊的 refresh token 被拿來重用時,目前只回 401。更嚴格的做法是把它視為外洩跡象,直接撤銷該用戶的所有會話
  • 超過 5 個會話時默默移除最舊的:使用者不會收到通知,最舊那台裝置下次請求時才發現被登出
  • 閒置 30 分鐘就登出:前端沒有任何操作時也不會發請求,所以離開 30 分鐘後回來一定要重新登入。對內部審查工具是合理的預設,可用 SESSION_INACTIVITY_MINUTES 調整

提交變更

git add src/auth.py src/session_manager.py src/api_auth.py \
        frontend/apiClient.js frontend/App.jsx tests/test_day17_refresh_token.py
git commit -m "Day 17: 刷新令牌輪換、伺服器端會話、登出撤銷、前端自動刷新"

第 2 週回顧

天 成果 關鍵數字
Day 8-10 規則擴展、語義分析、混合方案 F1 0.468 / 0.524 / 0.388,都低於 Day 7 的 0.789
Day 11 Mistral 7B 驗證與補充 F1 0.714,74.5 秒
Day 12 批量驗證與緩存 F1 0.767,56.1 秒,重跑 < 0.1 秒
Day 13-14 FastAPI 與 React 前端 任務模式、每秒輪詢
Day 15-17 帳號、隔離、會話 登出後 token 立即失效

系統已經能從瀏覽器登入、提交 SRS 需求、看到衝突結果,但檢測品質還沒超越 Day 7 的基線:Day 12 的 F1 0.767 仍比 0.789 低,而且 Day 11-13 看到的 Mistral 附和偏誤、漏掉的數值衝突都還沒解決。

明天預告

第 3 週回到檢測品質本身。明天 Day 18 會把 Day 10-13 累積的失敗案例(被放行的誤報、漏掉的數值衝突、補充層的英文與可疑結果)整理成一份清單,逐一分析原因,作為改進提示詞的依據。


上一篇
Day 16:API 認證集成與完整端到端系統
系列文
解決需求規格書矛盾:用 Claude Code × MCP 實作自律型文檔審查 Agent 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言