本文 AI 產出,尚未審核

FastAPI 課程 – 安全性(Security)

主題:OAuth2 Scopes


簡介

在現代的 Web API 中,授權(Authorization)與認證(Authentication)往往是同時出現的需求。FastAPI 內建支援 OAuth2 流程,讓開發者能以最少的程式碼實作安全的 API。
其中 Scope(範圍)是 OAuth2 的重要概念,它允許我們在同一個 token 內劃分不同的權限層級,從而達成「最小權限原則」的設計。

掌握 OAuth2 Scopes 不僅可以避免過度授權的安全風險,還能讓前端開發者在取得 token 時自行決定需要的功能集合,提升 API 的彈性與可維護性。因此,本單元將深入說明 FastAPI 中如何設定、驗證與使用 Scopes。


核心概念

1. OAuth2 Scope 是什麼?

  • Scope 是一個字串集合,用來描述 token 所擁有的權限。例如 ["read_items", "write_items"] 表示此 token 可以讀取與寫入項目。
  • 在 OAuth2 授權請求時,客戶端會透過 scope 參數告訴授權伺服器它需要的權限,授權伺服器則根據使用者同意的範圍產生相對應的 token。
  • FastAPI 透過 OAuth2PasswordBearerSecurity 類別,讓我們在路由層級直接宣告需要的 scope。

2. FastAPI 中的 Scope 設定流程

  1. 建立 OAuth2PasswordBearer,並在 tokenUrl 中指定取得 token 的端點。
  2. 在 token 產生邏輯(通常是 /token)中,根據使用者的角色或權限決定回傳的 scope。
  3. 在受保護的路由 使用 Security,傳入 scopes 參數,FastAPI 會自動檢查 token 中是否包含所需的 scope。
  4. 若檢查失敗,FastAPI 會拋出 HTTPException(status_code=401, detail="Not enough permissions")

3. Scope 與 Role 的差異

項目 Scope Role
定義方式 權限的原子單位(如 read, write 使用者的身份集合(如 admin, editor
彈性 可自由組合,多個 scope 組成一個較大權限 角色通常固定,較難細分
使用情境 需要對同一資源提供細粒度控制時 需要快速判斷使用者身份時

在實務上,我們常把 Role 轉換為預設的 Scope 集合,再交給 FastAPI 進行細部驗證。


程式碼範例

以下範例使用 Python(FastAPI)展示如何從頭到尾完成 OAuth2 Scopes 的設定與驗證。

範例 1:基礎設定與 Token 產生

# main.py
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from typing import List
from pydantic import BaseModel

app = FastAPI()

# 1. 建立 OAuth2PasswordBearer,指向 token 端點
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

# 2. 假資料:使用者與對應的 scope
fake_users_db = {
    "alice": {"username": "alice", "password": "secret", "scopes": ["read_items"]},
    "bob": {"username": "bob", "password": "secret", "scopes": ["read_items", "write_items"]},
}

class Token(BaseModel):
    access_token: str
    token_type: str

@app.post("/token", response_model=Token)
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
    """
    依據使用者提供的帳號密碼產生 JWT,並在 token 中寫入對應的 scopes。
    """
    user = fake_users_db.get(form_data.username)
    if not user or user["password"] != form_data.password:
        raise HTTPException(status_code=400, detail="Incorrect username or password")
    # 這裡直接回傳簡易的字串作為 token,實務上請使用 JWT
    token = f"{user['username']}|{' '.join(user['scopes'])}"
    return {"access_token": token, "token_type": "bearer"}

說明:此範例僅示範概念,實務上應使用 pyjwtpython-jose 產生簽名的 JWT,並在 token 中放入 scopes 欄位。

範例 2:驗證 Scope 的依賴函式

# utils.py
from fastapi import Security, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, SecurityScopes
from typing import List

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

def get_current_user(security_scopes: SecurityScopes, token: str = Depends(oauth2_scheme)):
    """
    解析 token,取得使用者名稱與其 scopes,並檢查是否包含請求所需的 scopes。
    """
    # 這裡的 token 格式為 "username|scope1 scope2"
    try:
        username, token_scopes_str = token.split("|")
        token_scopes = token_scopes_str.split()
    except Exception:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid authentication credentials",
        )
    # 檢查每一個所需的 scope 是否存在於 token 中
    for scope in security_scopes.scopes:
        if scope not in token_scopes:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail=f"Not enough permissions. Missing scope: {scope}",
            )
    return {"username": username, "scopes": token_scopes}

範例 3:在路由上使用不同的 Scopes

# main.py (續)
from utils import get_current_user, SecurityScopes, Security

@app.get("/items/", tags=["items"])
async def read_items(current_user: dict = Security(get_current_user, scopes=["read_items"])):
    """
    只需要 `read_items` 權限的端點。若 token 未包含此 scope,會回傳 401。
    """
    return {"msg": f"Hello {current_user['username']}, you can read items."}

@app.post("/items/", tags=["items"])
async def create_item(
    current_user: dict = Security(get_current_user, scopes=["write_items"])
):
    """
    需要 `write_items` 權限的端點。只有擁有此 scope 的使用者(如 bob)可以呼叫。
    """
    return {"msg": f"Hello {current_user['username']}, you can create items."}

範例 4:多重 Scope 結合(AND 與 OR)

# utils.py (續)

def get_current_user_or(security_scopes: SecurityScopes, token: str = Depends(oauth2_scheme)):
    """
    OR 方式檢查:只要 token 含有任意一個需求的 scope 即可通過。
    """
    username, token_scopes_str = token.split("|")
    token_scopes = token_scopes_str.split()
    # 若沒有任何需求 scope,直接通過
    if not security_scopes.scopes:
        return {"username": username, "scopes": token_scopes}
    # 檢查是否至少有一個符合
    if any(scope in token_scopes for scope in security_scopes.scopes):
        return {"username": username, "scopes": token_scopes}
    raise HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail=f"Missing required scopes: {security_scopes.scopes}",
    )
# main.py (續)

@app.get("/dashboard/", tags=["dashboard"])
async def view_dashboard(
    current_user: dict = Security(get_current_user_or, scopes=["read_items", "admin"])
):
    """
    只要 token 具備 `read_items` **或** `admin` 其中之一,就能存取此頁面。
    """
    return {"msg": f"Welcome {current_user['username']} to the dashboard."}

範例 5:使用 JWT 並自動解碼 Scopes

# jwt_utils.py
from jose import JWTError, jwt
from datetime import datetime, timedelta
from typing import List

SECRET_KEY = "super-secret-key"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

def create_access_token(data: dict, expires_delta: timedelta | None = None):
    to_encode = data.copy()
    expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
    to_encode.update({"exp": expire})
    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
    return encoded_jwt

def decode_access_token(token: str):
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        username: str = payload.get("sub")
        scopes: List[str] = payload.get("scopes", [])
        if username is None:
            raise JWTError()
        return {"username": username, "scopes": scopes}
    except JWTError:
        raise HTTPException(status_code=401, detail="Could not validate credentials")
# main.py (使用 JWT 版)
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jwt_utils import create_access_token, decode_access_token

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

@app.post("/token", response_model=Token)
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
    user = fake_users_db.get(form_data.username)
    if not user or user["password"] != form_data.password:
        raise HTTPException(status_code=400, detail="Incorrect username or password")
    access_token = create_access_token(
        data={"sub": user["username"], "scopes": user["scopes"]}
    )
    return {"access_token": access_token, "token_type": "bearer"}

def get_current_user(security_scopes: SecurityScopes, token: str = Depends(oauth2_scheme)):
    token_data = decode_access_token(token)
    for scope in security_scopes.scopes:
        if scope not in token_data["scopes"]:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail=f"Missing scope: {scope}",
            )
    return token_data

重點:使用 JWT 時,scopes 直接寫入 payload,FastAPI 只需要在依賴函式中解碼並比對即可。


常見陷阱與最佳實踐

陷阱 說明 解決方案
忘記在 token 中寫入 scopes 產生的 token 只包含 sub,導致授權檢查永遠失敗。 在產生 JWT 時,務必把 scopes 欄位加入 payload。
硬寫 scope 名稱 直接在程式碼寫 "read_items",日後若要變更會造成維護困難。 使用常數或 Enum 來統一管理 scope 名稱。
過度授權 為了方便,給所有 token ["*"] 或全部權限。 最小權限原則:只給需要的 scope,並在授權伺服器端驗證使用者的實際權限。
Scope 與 Role 混用 把 Role 直接當作 Scope,導致權限過於粗糙。 先把 Role 映射成一組預設 scope,再由 FastAPI 依 scope 做細粒度控制。
未處理 token 過期 token 過期後仍允許存取資源。 在 JWT 解碼時檢查 exp 欄位,或使用 fastapi.security.HTTPBearerauto_error=False 讓自訂錯誤處理更彈性。

最佳實踐

  1. 使用 Enum 管理 Scope
    from enum import Enum
    class Scopes(str, Enum):
        READ_ITEMS = "read_items"
        WRITE_ITEMS = "write_items"
        ADMIN = "admin"
    
  2. 把 Scope 驗證抽成可重用的依賴,如前面的 get_current_userget_current_user_or
  3. 在 OpenAPI 文件中說明每個端點所需的 scope,FastAPI 會自動產生 security 區塊,讓前端開發者一目了然。
  4. 使用 HTTPS,確保 token 在傳輸過程中不被竊聽。
  5. 定期旋轉密鑰(JWT secret),並在驗證失敗時回傳明確的錯誤訊息,避免資訊泄漏。

實際應用場景

場景 需求 可能的 Scope 設計
部落格平台 讀取文章、發表評論、編輯/刪除文章 read_posts, create_comments, edit_posts, delete_posts
企業內部系統 員工只能查詢自己的資料,管理者可以管理全部 self_read, self_write, admin_read, admin_write
IoT 裝置管理 裝置只能上傳感測資料,管理者可以遠端控制 upload_metrics, device_control
多租戶 SaaS 每個租戶只能存取自己的資源 tenant_{tenant_id}_read, tenant_{tenant_id}_write(動態產生)

範例:在 SaaS 中,我們可以在授權伺服器根據租戶 ID 動態產生 tenant_123_readtenant_123_write,然後在 FastAPI 路由使用 scopes=["tenant_123_read"],確保不同租戶之間互不干擾。


總結

  • OAuth2 Scopes 為 API 授權提供了細粒度的控制手段,是實踐最小權限原則的關鍵。
  • FastAPI 中,只需要透過 OAuth2PasswordBearerSecurity 與自訂的依賴函式,即可輕鬆完成 Scope 的驗證。
  • 實務上建議使用 JWT 來攜帶 scopes,搭配 Enum 統一管理名稱,並在授權伺服器端做好角色與 Scope 的映射。
  • 注意 過度授權忘記寫入 scopes、以及 未處理 token 過期 等常見陷阱,遵循 HTTPS、密鑰輪換 等安全最佳實踐。
  • 最後,將 Scope 設計與業務需求緊密結合,才能在真實專案中達到安全、彈性與易維護的平衡。

透過上述概念與範例,你已具備在 FastAPI 中使用 OAuth2 Scopes 的完整能力,接下來就可以把這套機制套用到自己的專案,為 API 打下堅實的安全基礎。祝開發順利!