OAuth 2.0 安全最佳實踐 - 從設計到實作

  1. 為什麼 OAuth 2.0 安全策略很重要
  2. OAuth 2.0 基礎
  3. 設計階段的 OAuth 安全策略
  4. 常見的 OAuth 2.0 漏洞
  5. OAuth 2.0 實作檢查清單
  6. OAuth 2.0 vs OpenID Connect
  7. OAuth 2.0 安全最佳實踐
  8. 做出選擇

你看過那些教學文章。複製這段程式碼,加入你的 client ID,然後砰——使用者就能用 Google 登入了。在開發環境中運作得很完美。你滿懷成就感地部署到正式環境。

然後你的資安團隊某個人隨口提到:「嘿,為什麼存取權杖會出現在我們的伺服器日誌裡?」或者更糟的是,滲透測試人員指出你的更新權杖存放在 localStorage 中,任何頁面上的腳本都能存取。突然間,那些教學程式碼感覺不再那麼可靠了。

安全的 OAuth 2.0 不是從整合函式庫時開始。它從應用程式設計時就開始了。你在撰寫程式碼之前做的決定,決定了你的授權流程是保護使用者還是讓他們暴露在攻擊之下。

這不是關於 OAuth 函式庫或身分提供者——而是關於策略。這是關於設計能預防漏洞的授權流程,而不是事後修補。

為什麼 OAuth 2.0 安全策略很重要

入侵測試:當攻擊者鎖定你的應用程式時,他們能竊取權杖、冒充使用者或存取未授權的資源嗎?還是你已經設計了讓攻擊變得不切實際的防禦機制?

不安全 OAuth 的代價

  • 帳號接管:攻擊者竊取權杖並存取使用者帳號
  • 資料外洩:洩漏的權杖暴露敏感使用者資料
  • 合規違規:GDPR、HIPAA 因安全不足而罰款
  • 聲譽損害:使用者在安全事件後失去信任

安全 OAuth 的價值

  • 使用者保護:權杖生命週期短、適當限定範圍且安全
  • 攻擊預防:PKCE、狀態驗證和安全儲存防止常見攻擊
  • 合規性:符合法規的安全要求
  • 信任:使用者對資料受到保護感到有信心

⚠️ 部署後無法修復 OAuth 安全問題

當權杖被洩露時,你無法追溯性地保護它們。你必須撤銷所有權杖、修復漏洞並強制使用者重新驗證。從一開始就設計好安全性。

OAuth 2.0 基礎

OAuth 2.0 是授權框架,不是驗證協定。它允許應用程式代表使用者存取資源,而不暴露憑證。

核心概念

資源擁有者(Resource Owner):擁有資料的使用者。

客戶端(Client):請求存取使用者資料的應用程式。

授權伺服器(Authorization Server):在驗證使用者後發放存取權杖(例如 Auth0、Okta、AWS Cognito)。

資源伺服器(Resource Server):託管受保護資源的 API(例如你的後端 API)。

存取權杖(Access Token):授予資源存取權限的短期憑證。

更新權杖(Refresh Token):用於取得新存取權杖的長期憑證。

範圍(Scope):定義客戶端可以存取哪些資源(例如 read:profilewrite:posts)。

OAuth 2.0 流程類型

授權碼流程(Authorization Code Flow):網頁應用程式最安全的方式。使用者驗證、接收授權碼、交換授權碼以取得權杖。

授權碼流程搭配 PKCE(Authorization Code Flow with PKCE):行動裝置和單頁應用程式的增強安全性。防止授權碼攔截。

隱式流程(Implicit Flow):已棄用。權杖直接在 URL 片段中回傳。容易發生權杖洩漏。

客戶端憑證流程(Client Credentials Flow):用於機器對機器通訊。不涉及使用者。

資源擁有者密碼憑證流程(Resource Owner Password Credentials Flow):已棄用。客戶端直接收集使用者名稱/密碼。除非絕對必要,否則避免使用。

⚠️ 絕不使用隱式流程

隱式流程在 URL 片段中回傳權杖,可能被記錄、快取或透過 Referer 標頭洩漏。永遠使用授權碼流程搭配 PKCE。

設計階段的 OAuth 安全策略

安全的 OAuth 需要在實作前進行規劃、標準化和架構決策。

選擇正確的流程

網頁應用程式(伺服器端):授權碼流程

使用者 → 登入 → 授權伺服器 → 授權碼 → 後端
後端 → 交換授權碼取得權杖 → 授權伺服器 → 存取權杖 + 更新權杖
後端 → 安全儲存權杖 → 資料庫(加密)

單頁應用程式(SPA):授權碼流程搭配 PKCE

使用者 → 登入 → 授權伺服器 → 授權碼
SPA → 交換授權碼 + 程式碼驗證器 → 授權伺服器 → 存取權杖
SPA → 將權杖儲存在記憶體中(不是 localStorage)

行動應用程式:授權碼流程搭配 PKCE

使用者 → 登入 → 授權伺服器 → 授權碼
應用程式 → 交換授權碼 + 程式碼驗證器 → 授權伺服器 → 存取權杖 + 更新權杖
應用程式 → 將更新權杖儲存在安全儲存中(Keychain/Keystore)

後端服務(無使用者):客戶端憑證流程

服務 → 使用客戶端 ID + 密鑰請求權杖 → 授權伺服器 → 存取權杖
服務 → 使用權杖進行 API 呼叫 → 資源伺服器

實作 PKCE(程式碼交換證明金鑰)

PKCE 防止授權碼攔截攻擊。行動裝置和 SPA 應用程式必須使用。

PKCE 運作方式

  1. 客戶端產生隨機 code_verifier(43-128 個字元)
  2. 客戶端建立 code_challenge = BASE64URL(SHA256(code_verifier))
  3. 客戶端在授權請求中傳送 code_challenge
  4. 授權伺服器儲存 code_challenge
  5. 客戶端交換授權碼 + code_verifier 以取得權杖
  6. 授權伺服器驗證 SHA256(code_verifier) 符合儲存的 code_challenge

為什麼 PKCE 重要:即使攻擊者攔截授權碼,沒有 code_verifier 也無法交換權杖。

// 產生 PKCE 參數
function generatePKCE() {
  const codeVerifier = generateRandomString(128);
  const codeChallenge = base64URLEncode(sha256(codeVerifier));
  
  return {
    codeVerifier,
    codeChallenge,
    codeChallengeMethod: 'S256'
  };
}

// 授權請求
const { codeVerifier, codeChallenge } = generatePKCE();
sessionStorage.setItem('code_verifier', codeVerifier);

window.location.href = `https://auth.neo01.com/authorize?
  client_id=${clientId}&
  redirect_uri=${redirectUri}&
  response_type=code&
  scope=openid profile email&
  code_challenge=${codeChallenge}&
  code_challenge_method=S256&
  state=${state}`;

// 權杖交換
const codeVerifier = sessionStorage.getItem('code_verifier');
const response = await fetch('https://auth.neo01.com/token', {
  method: 'POST',
  body: JSON.stringify({
    grant_type: 'authorization_code',
    code: authorizationCode,
    redirect_uri: redirectUri,
    client_id: clientId,
    code_verifier: codeVerifier
  })
});

驗證狀態參數

state 參數防止 OAuth 流程中的 CSRF 攻擊。

狀態驗證運作方式

  1. 客戶端在授權請求前產生隨機 state
  2. 客戶端將 state 儲存在 session 中
  3. 客戶端在授權請求中包含 state
  4. 授權伺服器與授權碼一起回傳 state
  5. 客戶端驗證回傳的 state 符合儲存的值

為什麼狀態重要:防止攻擊者誘騙使用者授權惡意應用程式。

// 產生並儲存狀態
const state = generateRandomString(32);
sessionStorage.setItem('oauth_state', state);

// 授權請求包含狀態
window.location.href = `https://auth.neo01.com/authorize?
  client_id=${clientId}&
  redirect_uri=${redirectUri}&
  response_type=code&
  scope=openid profile email&
  state=${state}`;

// 在回呼中驗證狀態
const returnedState = new URLSearchParams(window.location.search).get('state');
const storedState = sessionStorage.getItem('oauth_state');

if (returnedState !== storedState) {
  throw new Error('State validation failed - possible CSRF attack');
}

sessionStorage.removeItem('oauth_state');

定義範圍策略

範圍限制權杖可以存取的資源。遵循最小權限原則。

範圍命名慣例

read:profile     - 讀取使用者個人資料
write:profile    - 更新使用者個人資料
read:posts       - 讀取使用者貼文
write:posts      - 建立/更新貼文
delete:posts     - 刪除貼文
admin:users      - 管理所有使用者(僅限管理員)

範圍設計原則

  • 細粒度:分離讀取和寫入權限
  • 基於資源:每種資源類型的範圍(個人資料、貼文、留言)
  • 階層式admin:* 意味著所有權限
  • 最小化:僅請求當前操作所需的範圍
// 不好:預先請求所有範圍
const scopes = 'read:profile write:profile read:posts write:posts delete:posts admin:users';

// 好:請求最小範圍,需要時再請求更多
const scopes = 'read:profile read:posts';

// 稍後,當使用者想建立貼文時
const additionalScopes = 'write:posts';

權杖儲存策略

你儲存權杖的位置決定了安全性。

網頁應用程式(伺服器端)

  • 存取權杖:伺服器端 session 或加密資料庫
  • 更新權杖:加密資料庫,絕不傳送到瀏覽器
  • 絕不:localStorage、sessionStorage、JavaScript 可存取的 cookies

單頁應用程式

  • 存取權杖:僅記憶體(JavaScript 變數)
  • 更新權杖:不建議用於 SPA;使用短期存取權杖搭配靜默更新
  • 絕不:localStorage(容易受 XSS 攻擊)、sessionStorage(容易受 XSS 攻擊)

行動應用程式

  • 存取權杖:僅記憶體
  • 更新權杖:安全儲存(iOS Keychain、Android Keystore)
  • 絕不:共享偏好設定、UserDefaults、純文字檔案
// 不好:將權杖儲存在 localStorage(容易受 XSS 攻擊)
localStorage.setItem('access_token', accessToken);

// 好:僅儲存在記憶體中
let accessToken = null;

function setAccessToken(token) {
  accessToken = token;
}

function getAccessToken() {
  return accessToken;
}

// 登出或頁面卸載時清除
window.addEventListener('beforeunload', () => {
  accessToken = null;
});

權杖生命週期策略

平衡安全性和使用者體驗。

存取權杖

  • 生命週期:15 分鐘到 1 小時
  • 為什麼短:限制權杖被洩露時的損害
  • 更新:使用更新權杖取得新的存取權杖

更新權杖

  • 生命週期:7-90 天(或直到撤銷)
  • 為什麼長:避免頻繁強制使用者重新驗證
  • 輪換:每次使用時發放新的更新權杖,撤銷舊的

ID 權杖(OpenID Connect):

  • 生命週期:5-15 分鐘
  • 目的:使用者驗證,不是授權
  • 驗證:驗證簽章、發行者、受眾、過期時間
{
  "access_token_lifetime": 900,
  "refresh_token_lifetime": 2592000,
  "id_token_lifetime": 300,
  "refresh_token_rotation": true,
  "refresh_token_reuse_detection": true
}

更新權杖輪換

更新權杖輪換防止權杖重放攻擊。

輪換運作方式

  1. 客戶端使用更新權杖請求新的存取權杖
  2. 授權伺服器發放新的存取權杖 + 新的更新權杖
  3. 授權伺服器撤銷舊的更新權杖
  4. 如果舊的更新權杖再次被使用,撤銷整個權杖家族(表示洩露)

為什麼輪換重要:如果更新權杖被竊取,只能使用一次。後續使用會觸發所有權杖的撤銷。

// 帶輪換的權杖更新
async function refreshAccessToken(refreshToken) {
  const response = await fetch('https://auth.neo01.com/token', {
    method: 'POST',
    body: JSON.stringify({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: clientId
    })
  });

  const data = await response.json();
  
  // 儲存新權杖
  setAccessToken(data.access_token);
  await secureStorage.set('refresh_token', data.refresh_token);
  
  // 舊的更新權杖現在無效
  return data.access_token;
}

常見的 OAuth 2.0 漏洞

授權碼攔截

攻擊:攻擊者攔截授權碼並交換權杖。

預防:使用 PKCE。即使授權碼被攔截,沒有 code_verifier 攻擊者也無法交換權杖。

URL 中的權杖洩漏

攻擊:URL 參數中的權杖被記錄、快取或透過 Referer 標頭洩漏。

預防:絕不使用隱式流程。使用授權碼流程,透過 POST 請求交換權杖。

跨站腳本攻擊(XSS)

攻擊:攻擊者注入 JavaScript 從 localStorage 或 cookies 竊取權杖。

預防:僅將權杖儲存在記憶體中(SPA)或伺服器端(網頁應用程式)。使用內容安全政策(CSP)。

跨站請求偽造(CSRF)

攻擊:攻擊者誘騙使用者授權惡意應用程式。

預防:驗證 state 參數。確保狀態是隨機的、不可預測的,並與使用者 session 綁定。

更新權杖竊取

攻擊:攻擊者竊取更新權杖並取得無限的存取權杖。

預防:實作更新權杖輪換。偵測重複使用並撤銷權杖家族。

開放重新導向

攻擊:攻擊者操縱 redirect_uri 以竊取授權碼。

預防:在授權伺服器中將確切的重新導向 URI 加入白名單。絕不允許萬用字元或部分符合。

// 不好:允許任何 redirect_uri
const redirectUri = req.query.redirect_uri; // 攻擊者控制這個

// 好:將確切的 URI 加入白名單
const allowedRedirectUris = [
  'https://app.neo01.com/callback',
  'https://app.neo01.com/auth/callback'
];

if (!allowedRedirectUris.includes(redirectUri)) {
  throw new Error('Invalid redirect_uri');
}

OAuth 2.0 實作檢查清單

授權流程

  • ✅ 使用授權碼流程(不是隱式流程)
  • ✅ 為行動裝置和 SPA 應用程式實作 PKCE
  • ✅ 驗證 state 參數以防止 CSRF
  • ✅ 將確切的重新導向 URI 加入白名單(無萬用字元)

權杖管理

  • ✅ 存取權杖在 15-60 分鐘後過期
  • ✅ 更新權杖在 7-90 天後過期
  • ✅ 實作更新權杖輪換
  • ✅ 偵測並撤銷重複使用的更新權杖

權杖儲存

  • ✅ 將存取權杖儲存在記憶體中(SPA)或伺服器端(網頁應用程式)
  • ✅ 將更新權杖儲存在安全儲存中(行動裝置)或伺服器端(網頁應用程式)
  • ✅ 絕不將權杖儲存在 localStorage 或 sessionStorage

範圍管理

  • ✅ 請求所需的最小範圍
  • ✅ 在資源伺服器上驗證範圍
  • ✅ 使用細粒度、基於資源的範圍

安全標頭

  • ✅ 實作內容安全政策(CSP)
  • ✅ 所有 OAuth 端點使用 HTTPS
  • ✅ 在 cookies 上設定 Secure 和 HttpOnly 旗標

監控

  • ✅ 記錄驗證事件(登入、登出、權杖更新)
  • ✅ 對可疑模式發出警報(多次登入失敗、權杖重複使用)
  • ✅ 監控權杖使用和過期

OAuth 2.0 vs OpenID Connect

OAuth 2.0:授權框架。回答「這個應用程式可以存取什麼?」

OpenID Connect:OAuth 2.0 之上的驗證層。回答「這個使用者是誰?」

何時使用 OAuth 2.0:授予資源存取權限(API、資料)。

何時使用 OpenID Connect:使用者驗證和身分確認。

關鍵差異:OpenID Connect 新增包含使用者身分資訊的 ID 權杖(JWT)。

// OAuth 2.0:僅存取權杖
{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "read:profile read:posts"
}

// OpenID Connect:存取權杖 + ID 權杖
{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "openid profile email"
}

OAuth 2.0 安全最佳實踐

全面使用 HTTPS

所有 OAuth 端點都必須使用 HTTPS。透過 HTTP 傳輸的權杖可能被攔截。

驗證 JWT 簽章

如果使用 JWT 作為存取權杖,務必驗證簽章、發行者、受眾和過期時間。

const jwt = require('jsonwebtoken');

function validateAccessToken(token) {
  try {
    const decoded = jwt.verify(token, publicKey, {
      issuer: 'https://auth.neo01.com',
      audience: 'https://api.neo01.com'
    });
    return decoded;
  } catch (error) {
    throw new Error('Invalid token');
  }
}

實作速率限制

防止對權杖端點的暴力攻擊。

POST /token: 每個 IP 每分鐘 5 次請求
POST /authorize: 每個使用者每分鐘 10 次請求

登出時撤銷權杖

當使用者登出時,撤銷所有權杖(存取和更新)。

async function logout(userId, refreshToken) {
  // 撤銷更新權杖
  await revokeRefreshToken(refreshToken);
  
  // 撤銷使用者的所有活動 session
  await revokeAllUserSessions(userId);
  
  // 清除客戶端權杖
  setAccessToken(null);
  await secureStorage.remove('refresh_token');
}

監控權杖使用

記錄並監控與權杖相關的事件以進行安全分析。

INFO [SECURITY.AUTH]: 使用者登入成功 | user_id={id} ip={ip}
WARNING [SECURITY.AUTH]: 權杖更新失敗 | user_id={id} reason={expired}
ALERT [SECURITY.AUTH]: 偵測到更新權杖重複使用 | user_id={id} token_id={id}
CRITICAL [SECURITY.AUTH]: 觸發權杖撤銷 | user_id={id} reason={reuse_detected}

做出選擇

OAuth 2.0 安全不是選項——它是必要的。問題在於你是從一開始就正確設計,還是在安全事件後才進行改造。

從正確的流程開始:現代應用程式使用授權碼搭配 PKCE。實作狀態驗證、更新權杖輪換和安全儲存。定義最小範圍和短權杖生命週期。

記住:OAuth 2.0 是你應用程式的授權框架。正確實作時,它保護使用者並防止未授權存取。實作不當時,它成為你安全性中最薄弱的環節。

從一開始就正確設計 OAuth 安全。你的使用者——以及你的資安團隊——會感謝你。

分享到