iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0

Day 15 我們建好了帳號系統:users 與 jobs 兩張表、PBKDF2 密碼雜湊、JWT 登入,以及 get_current_user 這個「取得目前登入者」的依賴。但它還沒保護任何東西:Day 13 的檢測端點 /api/v1/conflicts/detect 誰都能用,任務也還存在記憶體裡。

今天把兩邊接起來:

  • 新增需要登入的檢測端點 /api/v1/detect
  • 任務寫進 Day 15 的 jobs 表,伺服器重啟後還在
  • 每個用戶只看得到自己的任務
  • 前端加上登入畫面,並重用 Day 14 的檢測頁

這一天在系列中的位置

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

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

今日目標

今天要完成:

  1. 受保護的檢測端點 POST /api/v1/detect(src/api_main.py):沒有 token 回 401
  2. 任務持久化:背景任務把結果寫進 jobs 表
  3. 用戶隔離 GET /api/v1/auth/jobs、GET /api/v1/auth/jobs/{job_id}(src/api_auth.py):他人的任務回 403
  4. 登入版前端 frontend/App.jsx:註冊、登入、儀表板、檢測、任務列表
  5. 測試 tests/test_day16_api_auth.py:6 個測試,可重複執行,不會清空資料庫

問題背景:「需要登入」要做到哪幾件事

「加上登入」聽起來只是檢查 token,但實際上有三層:

層次 問題 今天的做法
認證(Authentication) 你是誰? Depends(get_current_user),沒有有效 token 回 401
歸屬 這個任務是誰的? 建立任務時寫入 user_id = current_user.id
授權(Authorization) 你能看這個任務嗎? 查詢時比對 job.user_id,不是自己的回 403

只做第一層是最常見的漏洞:大家都要登入,但登入後改一下網址裡的 job_id,就能看到別人的資料(OWASP 稱為 Broken Object Level Authorization)。


實現方法

POST /api/v1/detect  (Authorization: Bearer <token>)
  → get_current_user → normalize_constraints(格式錯誤回 400)
  → 寫入 jobs(status=processing, user_id=目前用戶)
  → 立即回傳 job_id
  → 背景:run_detection_with_db(自己開 Session)→ 檢測 → 更新 jobs.results

GET /api/v1/auth/jobs            → 只查 user_id = 目前用戶 的任務
GET /api/v1/auth/jobs/{job_id}   → 找不到 404;不是自己的 403;否則回傳結果
  • 輸入:登入者的 token、需求清單
  • 輸出:jobs 表中的任務、依用戶隔離的查詢結果
  • 檔案:src/api_main.py、src/api_auth.py、frontend/App.jsx、frontend/apiClient.js(修改);tests/test_day16_api_auth.py(新增)
  • 下游:Day 17 會在 get_current_user 裡加上會話檢查,讓登出真正生效

專案結構變化:

srs-review-agent/
├── src/
│   ├── api_main.py            ← 修改:新增 /api/v1/detect、run_detection_with_db
│   └── api_auth.py            ← 修改:新增 /jobs、/jobs/{job_id}
├── frontend/
│   ├── index.jsx              ← 修改:預設改為啟動 App(VITE_PUBLIC_MODE=1 才是 Day 14 版)
│   ├── App.jsx                ← 【新增】登入版根元件
│   └── apiClient.js           ← 修改:新增 authApi、authRequest、tokenStore
└── tests/
    └── test_day16_api_auth.py ← 【新增】6 個測試

今天不需要新的套件。


代碼示例

1. 受保護的檢測端點

修改 src/api_main.py,新增 /api/v1/detect:

@app.post("/api/v1/detect", tags=["衝突檢測"])
async def detect_conflicts(
    request: DetectionRequest,
    background_tasks: BackgroundTasks,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db)
):
    constraints = request.constraints if isinstance(request.constraints, list) else []
    if len(constraints) < 2:
        raise HTTPException(status_code=400, detail="至少需要 2 個約束")
    if len(constraints) > 500:
        raise HTTPException(status_code=400, detail="最多 500 個約束")
    try:
        normalize_constraints(constraints)   # 格式錯誤在提交時就回 400
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

    job_id = str(uuid.uuid4())
    db.add(Job(id=job_id, user_id=current_user.id, status="processing",
               constraints=constraints))
    db.commit()

    # 不把請求的 db 傳給背景任務:它何時被 get_db() 關閉取決於 FastAPI 版本,
    # 而且背景任務在 threadpool 的另一個執行緒執行,Session 不應跨執行緒共用
    background_tasks.add_task(run_detection_with_db, job_id, constraints)
    return {"job_id": job_id, "status": "queued", "message": "檢測任務已提交"}

和 Day 13 的 /api/v1/conflicts/detect 相比,只差在參數多了 current_user: User = Depends(get_current_user)。FastAPI 會先執行 get_current_user:沒有 token 或 token 無效就直接回 401,端點本身根本不會執行。

user_id=current_user.id 是之後做隔離的基礎:歸屬在建立時就要記錄,事後補不回來。

normalize_constraints() 沿用 Day 13 的函數,同時接受純文字 ["...", "..."](前端送的格式,自動編號 REQ-1…)與 [{id, text}](保留原本的編號)。

2. 背景任務:自己開 Session

同一個檔案中的背景任務:

def run_detection_with_db(job_id: str, constraints: list):
    db = SessionLocal()
    try:
        job = db.query(Job).filter(Job.id == job_id).first()
        if job is None:
            return
        try:
            detected = detector.detect_conflicts(
                normalize_constraints(constraints), **detection_kwargs())
            job.results = {
                "conflicts_found": len(detected),
                "conflicts": [{"req_id_1": c.req_id_1, "req_id_2": c.req_id_2,
                               "type": c.conflict_type.value, "severity": c.severity.value,
                               "description": c.description, "confidence": c.confidence,
                               "verified": c.verified} for c in detected],
            }
            job.status = "completed"
        except Exception as e:
            job.status = "failed"
            job.error = str(e)
        job.completed_at = datetime.utcnow()
        db.commit()
    finally:
        db.close()

這段原本有三個問題,都是檢查時發現的:

  1. 沿用請求的 Session:原本寫成 add_task(run_detection_with_db, job_id, constraints, db),把請求的 db 傳進背景任務。Day 15 的 get_db() 會在 finally 關閉它,但「先關閉還是先跑背景任務」在 FastAPI 不同版本間改過。我用本文環境的 FastAPI 0.141.1 實測,順序是「背景任務執行 → Session 關閉」,所以原寫法在這個版本碰巧能動;但它依賴特定版本的行為,而且改成同步函數後,背景任務在 threadpool 的另一個執行緒執行,SQLAlchemy 的 Session 不應跨執行緒共用。現在改成背景任務自己 SessionLocal(),用完在 finally 關閉
  2. async def 卡住伺服器:和 Day 13 一樣,detect_conflicts() 是同步呼叫,使用 Ollama 時要十幾秒。寫成一般的 def,FastAPI 才會丟到 threadpool 執行
  3. 只認純文字:原本用 {"id": f"REQ-{i+1}", "text": c} 組約束,送 [{id, text}] 時 text 變成一個 dict,任務直接失敗。改用 normalize_constraints()

job.status = "failed" 與 job.error 也會寫回資料庫,前端才能顯示失敗原因,而不是永遠停在 processing。

3. 用戶隔離:任務列表與任務詳情

修改 src/api_auth.py,新增兩個端點:

@router.get("/jobs")
async def list_user_jobs(current_user: User = Depends(get_current_user),
                         db: Session = Depends(get_db)):
    jobs = (
        db.query(Job)
        .filter(Job.user_id == current_user.id)
        .order_by(Job.created_at.desc())   # 最新的任務在最前面
        .all()
    )
    return {
        "user_id": current_user.id,
        "total": len(jobs),
        "jobs": [{"id": job.id, "status": job.status,
                  "created_at": _utc_iso(job.created_at),
                  "completed_at": _utc_iso(job.completed_at),
                  "constraints_count": len(job.constraints) if job.constraints else 0}
                 for job in jobs],
    }

列表的隔離很直接:查詢條件永遠帶著 Job.user_id == current_user.id,不是自己的任務根本不會被撈出來。

任務詳情則要先找到任務,再檢查歸屬:

@router.get("/jobs/{job_id}")
async def get_user_job(job_id: str, current_user: User = Depends(get_current_user),
                       db: Session = Depends(get_db)):
    job = db.query(Job).filter(Job.id == job_id).first()
    if not job:
        raise HTTPException(status_code=404, detail="任務不存在")
    if job.user_id != current_user.id:
        raise HTTPException(status_code=403, detail="無權訪問此任務")

    return {"id": job.id, "status": job.status,
            "created_at": _utc_iso(job.created_at),
            "completed_at": _utc_iso(job.completed_at),
            "results": job.results, "error": job.error}

回傳的 results 與 Day 13 的 /api/v1/conflicts/{job_id} 格式相同,所以 Day 14 的 DetectPage 不用改就能讀。

4. 時間要帶時區

_utc_iso() 是測試前端時發現的問題:

def _utc_iso(dt):
    """資料庫存的是不帶時區的 UTC 時間;輸出時補上 Z"""
    return dt.isoformat() + "Z" if dt else None

jobs.created_at 用 datetime.utcnow() 寫入,是不帶時區資訊的 UTC 時間。原本直接輸出 2026-09-29T06:30:38.972438,瀏覽器的 new Date() 遇到沒有時區的字串會當成本地時間:

new Date("2026-09-29T06:30:38.972438")   → 2026/9/29 上午6:30:38   ✗(台灣實際是下午 2:30)
new Date("2026-09-29T06:30:38.972438Z")  → 2026/9/29 下午2:30:38   ✓

任務列表的「創建時間」因此比實際早了 8 小時。補上 Z 明確標示是 UTC,瀏覽器就會正確轉成使用者的時區。

5. 前端:登入版

建立 frontend/App.jsx。登入要用表單格式送出(Day 15 的 OAuth2 password flow):

const formDataUrlEncoded = new URLSearchParams()
formDataUrlEncoded.append('username', formData.email)
formDataUrlEncoded.append('password', formData.password)

response = await fetch(`${API_BASE}/auth/login`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: formDataUrlEncoded
})
...
if (response.ok) {
  tokenStore.set(data.access_token, data.refresh_token)
  setCurrentUser(data.user)
  setIsAuthenticated(true)
  loadUserJobs()
}

登入後的「上傳文檔」頁直接重用 Day 14 的 DetectPage,只是換一個 API 客戶端:

<DetectPage api={authApi} onSubmitted={() => loadUserJobs()} />

authApi 定義在 frontend/apiClient.js,介面和 Day 14 的 publicApi 一樣是 submit / getJob,只是換成需要登入的端點:

export const authApi = {
  submit: (lines) =>
    authRequest('/detect', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ constraints: lines }),
    }),
  getJob: (jobId) => authRequest(`/auth/jobs/${jobId}`),
}

authRequest() 會從 tokenStore(localStorage)讀出 token,加上 Authorization: Bearer ... 標頭;Day 17 會再讓它在 token 過期時自動刷新。authApi 必須是模組層級的常數。Day 14 提過,DetectPage 的輪詢 useEffect 依賴 api,每次 render 都產生新物件的話,輪詢會不斷重啟。

frontend/index.jsx 預設啟動 App,設定 VITE_PUBLIC_MODE=1 時才啟動 Day 14 的免登入版。任務列表、儀表板與側邊欄的完整代碼見 frontend/App.jsx。

檢查時還修了一個前端 bug:原本登入後呼叫 setToken(data.access_token) 更新 React state,緊接著呼叫 loadUserJobs()。但 state 更新要等下一次 render 才生效,loadUserJobs 讀到的還是舊的空 token,第一次載入任務列表一定是 401。現在 token 一律從 tokenStore 讀取,寫入後立刻就能拿到。


驗證結果

測試

cd srs-review-agent
python3 -m pytest tests/test_day16_api_auth.py -v
tests/test_day16_api_auth.py::test_1_user_registration PASSED            [ 16%]
tests/test_day16_api_auth.py::test_2_user_login PASSED                   [ 33%]
tests/test_day16_api_auth.py::test_3_protected_endpoints PASSED          [ 50%]
tests/test_day16_api_auth.py::test_4_detect_with_auth PASSED             [ 66%]
tests/test_day16_api_auth.py::test_5_user_isolation PASSED               [ 83%]
tests/test_day16_api_auth.py::test_6_constraint_formats PASSED           [100%]

============================== 6 passed in 0.40s ===============================
  • test_4_detect_with_auth:無 token 回 401;有 token 時任務 completed,並找到 REQ-1 ↔ REQ-2 這 1 個衝突
  • test_5_user_isolation:用戶 2 的列表裡沒有用戶 1 的任務,直接存取回 403
  • test_6_constraint_formats:[{id, text}] 保留原編號(REQ-2.1 ↔ REQ-4.3),格式錯誤回 400

注意:原本的測試檔在直接執行時會對資料庫 drop_all,把 ./test.db 整個清空重建。現在每次執行都用不重複的測試郵箱,不需要清空資料庫,可以重複執行。不想寫入開發資料庫時,可以設定 DATABASE_URL=sqlite:////tmp/test_day16.db。

手動測試

以下假設已經照 Day 15 註冊並登入 alice 與 bob,$ALICE、$BOB 分別是兩人的 access token。

1. 沒有 token 提交檢測

curl -X POST http://localhost:8000/api/v1/detect \
  -H "Content-Type: application/json" -d '{"constraints": ["a", "b"]}'
# 401

2. alice 提交檢測

curl -X POST http://localhost:8000/api/v1/detect \
  -H "Authorization: Bearer $ALICE" -H "Content-Type: application/json" \
  -d '{"constraints": ["系統支持多用戶並行存取", "系統採用單用戶模式", "所有數據必須加密存儲"]}'
{"job_id": "e5b21b64-f0fb-4c16-9d10-4522283bb562", "status": "queued", "message": "檢測任務已提交"}

3. alice 查詢自己的任務

curl http://localhost:8000/api/v1/auth/jobs/e5b21b64-f0fb-4c16-9d10-4522283bb562 \
  -H "Authorization: Bearer $ALICE"
{"id": "e5b21b64-f0fb-4c16-9d10-4522283bb562", "status": "completed",
 "created_at": "2026-09-29T06:30:38.972438Z", "completed_at": "2026-09-29T06:30:38.973754Z",
 "results": {"conflicts_found": 1, "conflicts": [
   {"req_id_1": "REQ-1", "req_id_2": "REQ-2", "type": "邏輯矛盾", "severity": "高",
    "description": "檢測到 多用戶 vs 單用戶", "confidence": 0.95, "verified": false}]},
 "error": null}

4. 隔離

請求 結果
bob 查詢 /api/v1/auth/jobs total: 0,看不到 alice 的任務
bob 查詢 alice 的 /api/v1/auth/jobs/e5b21b64-... 403「無權訪問此任務」
alice 查詢不存在的 /api/v1/auth/jobs/nonexistent 404「任務不存在」

瀏覽器端對端測試

後端與前端都啟動後(uvicorn src.api_main:app --port 8000、cd frontend && npm run dev),用 Playwright 驅動 headless Chromium 跑完整流程:

200 POST /api/v1/auth/register
200 POST /api/v1/auth/login
200 GET  /api/v1/auth/jobs
200 POST /api/v1/detect
200 GET  /api/v1/auth/jobs
200 GET  /api/v1/auth/jobs/<id>

註冊 → 登入 → 在「上傳文檔」頁輸入 系統支持多用戶並行存取、系統採用單用戶模式 → 顯示「發現 1 個衝突」,console 沒有錯誤。修正前,登入後的第一個 GET /api/v1/auth/jobs 是 401。


權衡與限制

  • Day 13 的免登入端點還在:/api/v1/conflicts/* 仍然不需要登入,任何人都能用 GET /api/v1/conflicts 列出所有免登入任務。保留它是為了 Day 14 的免登入版與 Day 13 的測試;正式部署時應該關閉或加上認證
  • 403 會透露任務存在:用不存在的 id 查詢得到 404,別人的 id 得到 403,攻擊者可以藉此判斷 id 是否存在。因為 id 是 UUID、猜不到,這裡選擇讓訊息比較清楚;更保守的做法是兩種情況都回 404
  • 登出還沒有真的作廢 token:Day 15 提過,JWT 在到期前都有效,明天處理
  • SQLite 與背景任務:多個背景任務同時寫入時,SQLite 會對整個資料庫加鎖。單機開發沒問題,多人使用時建議換成 PostgreSQL

提交變更

git add src/api_main.py src/api_auth.py frontend/App.jsx frontend/apiClient.js \
        frontend/index.jsx tests/test_day16_api_auth.py
git commit -m "Day 16: 需要登入的檢測端點、任務持久化、用戶隔離、登入版前端"

明天預告

現在每個請求都需要 token,但 token 只有 15 分鐘效期,過期就得重新登入;而且 Day 15 看到,登出後 token 其實還能用。明天 Day 17 會加入刷新令牌(refresh token)與會話管理:access token 過期時自動換新,登出時真正作廢 token,閒置太久自動登出。


上一篇
Day 15:用戶認證與數據庫持久化
系列文
解決需求規格書矛盾:用 Claude Code × MCP 實作自律型文檔審查 Agent 共 16 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言