[安全設計] 003 認證與授權架構設計實戰指南:從密碼到 Zero Trust|身份驗證、RBAC、ABAC 與 Node.js 程式碼範例

「門鎖決定你能不能進門;門禁卡的權限,決定你進得了哪幾個房間。
認證是驗身分,授權是給權限——搞混了,就會把不該進的人放進去。」
— SSDLC by 飛飛


一、認證與授權是什麼?為什麼你不能只靠一組帳密打天下?

在 SSDLC 的「蓋房子」旅程中,我們已經走過了安全需求定義(確認房子要防什麼)、威脅建模(找出小偷從哪進來)、安全設計原則(建築師的防禦哲學)。現在,我們要進入安全設計階段最實戰的主題——認證(Authentication)與授權(Authorization)

先用一個生活場景來分清楚這兩件事。

想像你走進一棟辦公大樓。大廳的保全要你刷員工證——這是認證,確認「你是誰」。你刷了卡,保全確認你是合法員工,讓你進入大樓。但這不代表你可以走進每間辦公室。你能進自己部門的樓層,卻不能刷進財務部的金庫——這是授權,決定「你能做什麼」。

在軟體系統中也一樣:

概念 英文 回答的問題 生活比喻
認證 Authentication(AuthN) 你是誰? 刷員工證進大樓
授權 Authorization(AuthZ) 你能做什麼? 只能進自己部門的樓層

這兩件事看似簡單,卻是 OWASP Top 10 中排名前三的重災區。A01:2021 — Broken Access Control(權限控制失效) 連續多年高居榜首,而 A07:2021 — Identification and Authentication Failures(認證失效) 也名列前茅。換句話說,全世界最常見的資安漏洞,就是「搞不清楚誰是誰」和「管不住誰能做什麼」。

飛飛觀點:
很多開發者花了大量時間防 SQL Injection 和 XSS,卻忘了最基本的事:你的系統真的確認了「這個請求是誰發的」嗎?他真的有權限做這件事嗎?把認證和授權做對,比擋住任何單一攻擊都重要。


二、認證機制選擇:從「你知道什麼」到「你是什麼」

認證的本質是回答一個問題:「你怎麼證明你是你?

根據認證因子(Authentication Factor)的不同,可以分成三大類:

認證因子 英文 說明 範例
你知道的東西 Something you know 只有你知道的秘密 密碼、PIN 碼、安全問題
你擁有的東西 Something you have 你持有的實體或數位物品 手機 OTP、硬體金鑰、智慧卡
你本身的特徵 Something you are 你的生物特徵 指紋、臉部辨識、虹膜掃描

單獨使用任何一種因子都不夠安全。密碼會被猜到、手機會被偷、指紋甚至可以被複製。所以現代系統的最佳實踐是多因素認證(MFA, Multi-Factor Authentication)——至少使用兩種不同類別的因子。

2.1 密碼:最古老也最脆弱的認證方式

密碼是最普遍的認證方式,也是最容易出問題的。以下是開發者在處理密碼時最常犯的錯誤:

常見錯誤 為什麼危險 正確做法
密碼明碼存資料庫 資料庫被入侵 = 所有密碼外洩 使用 Argon2id 或 bcrypt 雜湊
限制密碼長度上限為 8 碼 8 碼密碼可在數小時內被暴力破解 最低 12 碼,上限至少 64 碼
要求定期換密碼(每 90 天) 使用者只會在舊密碼後加 1、2、3 NIST 建議不再強制定期更換(SP 800-63B)
限制不能用特殊符號 縮小密碼空間,降低破解難度 允許所有 Unicode 字元
自己寫密碼雜湊邏輯 幾乎肯定有漏洞 使用成熟的函式庫

Node.js 密碼雜湊範例:

const argon2 = require('argon2');

// ✅ 註冊時:雜湊密碼
async function hashPassword(plainPassword) {
  return await argon2.hash(plainPassword, {
    type: argon2.argon2id,  // 使用 Argon2id 變體(抵抗 GPU 和 side-channel 攻擊)
    memoryCost: 65536,      // 64 MB 記憶體
    timeCost: 3,            // 迭代次數
    parallelism: 4          // 平行度
  });
}

// ✅ 登入時:驗證密碼
async function verifyPassword(plainPassword, hashedPassword) {
  return await argon2.verify(hashedPassword, plainPassword);
}

// ✅ 完整的登入流程
async function login(email, password) {
  const user = await db.query(
    'SELECT id, password_hash, login_attempts, locked_until FROM users WHERE email = $1',
    [email]
  );

  // 不管帳號存不存在,都要做雜湊比對(防止 Timing Attack)
  if (!user) {
    await argon2.hash('dummy-password');  // 花費相同時間
    return { success: false, message: '帳號或密碼錯誤' };
  }

  // 檢查帳號是否被鎖定
  if (user.locked_until && user.locked_until > new Date()) {
    return { success: false, message: '帳號已鎖定,請稍後再試' };
  }

  const isValid = await verifyPassword(password, user.password_hash);

  if (!isValid) {
    // 累加失敗次數,5 次後鎖定 15 分鐘
    await incrementLoginAttempts(user.id);
    await logSecurityEvent('LOGIN_FAILED', { userId: user.id, ip: req.ip });
    return { success: false, message: '帳號或密碼錯誤' };  // 不透露是帳號錯還是密碼錯
  }

  // 登入成功,重置失敗次數
  await resetLoginAttempts(user.id);
  await logSecurityEvent('LOGIN_SUCCESS', { userId: user.id, ip: req.ip });

  return { success: true, token: generateJWT(user) };
}

2.2 多因素認證(MFA):不要把所有雞蛋放在密碼這個籃子裡

密碼再強也可能被釣魚、被社交工程、被資料庫外洩。MFA 的價值在於:即使密碼被盜,攻擊者還需要突破另一道關卡。

常見的 MFA 方式比較:

MFA 方式 安全性 便利性 成本 適合場景
簡訊 OTP ⭐⭐ ⭐⭐⭐⭐ 一般會員系統(但注意 SIM Swap 攻擊)
TOTP(如 Google Authenticator) ⭐⭐⭐ ⭐⭐⭐ 免費 內部系統、開發者工具
推播通知(如 Duo、Line Notify) ⭐⭐⭐ ⭐⭐⭐⭐ 企業內部系統
FIDO2/WebAuthn(硬體金鑰、Passkey) ⭐⭐⭐⭐⭐ ⭐⭐⭐ 高安全需求(金融、管理員帳號)
Email OTP ⭐⭐ ⭐⭐ 免費 低風險操作的二次確認

飛飛觀點:
簡訊 OTP 很常見,但它並不像你以為的那麼安全。SIM Swap 攻擊(打電話給電信商假裝是你,把你的門號轉到攻擊者的 SIM 卡)在台灣已經有實際案例。如果你的系統處理金流或敏感資料,建議至少用 TOTP,最好支援 FIDO2/Passkey。

2.3 Passkey 與 FIDO2:密碼的終結者

Passkey 是 FIDO Alliance 推動的下一代認證標準,目標是徹底取代密碼。它的原理是:

  1. 使用者的裝置(手機、電腦)生成一對公私鑰
  2. 公鑰存在伺服器,私鑰留在裝置上
  3. 認證時,伺服器發出挑戰(Challenge),裝置用私鑰簽名回應
  4. 伺服器用公鑰驗證簽名

這意味著:伺服器上完全沒有密碼或密碼雜湊,就算資料庫被入侵,攻擊者也拿不到任何可以用來登入的東西。

// Passkey 註冊流程(使用 @simplewebauthn/server)
const { generateRegistrationOptions, verifyRegistrationResponse }
  = require('@simplewebauthn/server');

// 步驟一:產生註冊選項
app.post('/api/auth/passkey/register-options', async (req, res) => {
  const user = req.user;

  const options = await generateRegistrationOptions({
    rpName: '我的台灣電商',          // 你的服務名稱
    rpID: 'myshop.com.tw',          // 你的網域
    userID: user.id,
    userName: user.email,
    attestationType: 'none',         // 一般應用不需要硬體認證
    authenticatorSelection: {
      residentKey: 'preferred',      // 支援 Passkey(Discoverable Credential)
      userVerification: 'preferred'  // 偏好生物辨識驗證
    }
  });

  // 暫存 challenge,等驗證時比對
  await saveChallenge(user.id, options.challenge);
  res.json(options);
});

// 步驟二:驗證註冊結果
app.post('/api/auth/passkey/register-verify', async (req, res) => {
  const user = req.user;
  const expectedChallenge = await getChallenge(user.id);

  const verification = await verifyRegistrationResponse({
    response: req.body,
    expectedChallenge,
    expectedOrigin: 'https://myshop.com.tw',
    expectedRPID: 'myshop.com.tw'
  });

  if (verification.verified) {
    // 儲存公鑰,之後登入驗證用
    await savePasskey(user.id, verification.registrationInfo);
    res.json({ success: true });
  }
});

2.4 身份驗證機制選擇指南

面對這麼多認證方式,怎麼選?以下是根據不同場景的建議:

場景 建議認證方式 理由
一般電商會員 密碼 + Email OTP(敏感操作時) 平衡安全與便利
網路銀行 / 金融 App 密碼 + TOTP/推播 + 生物辨識 金管會要求,高風險場景
內部管理後台 SSO + MFA(TOTP 或硬體金鑰) 管理員帳號一旦被盜,影響全系統
API 服務對接 API Key + mTLS 機器對機器,不需要人類互動
微服務之間 JWT + Service Mesh mTLS 服務間信任建立
高安全需求(政府、軍事) FIDO2 硬體金鑰 + 智慧卡 最高安全等級

三、Token 機制:認證之後,怎麼「記住」你?

使用者通過認證後,系統需要一種方式來「記住」他已經登入了,不用每次操作都重新輸入密碼。這就是 Token 的角色。

3.1 Session vs. JWT:兩種主流方案

比較維度 Session(伺服器端) JWT(客戶端)
狀態儲存 伺服器(記憶體或 Redis) 客戶端(Cookie 或 LocalStorage)
擴展性 需要共享 Session Store(如 Redis) 天然適合分散式架構
撤銷機制 簡單(直接刪除 Session) 困難(需要額外的黑名單機制)
適合場景 傳統 Web 應用、單體架構 SPA、行動 App、微服務架構
安全考量 CSRF 攻擊 Token 過期管理、儲存安全

JWT 的正確使用方式:

const jwt = require('jsonwebtoken');

// ✅ JWT 簽發:只放必要資訊,不放敏感資料
function generateTokens(user) {
  const accessToken = jwt.sign(
    {
      sub: user.id,           // 使用者 ID
      role: user.role,        // 角色(但授權時要再到 DB 確認!)
      type: 'access'
    },
    process.env.JWT_SECRET,
    {
      expiresIn: '15m',       // Access Token 短效期:15 分鐘
      issuer: 'myshop.com.tw',
      audience: 'myshop-api'
    }
  );

  const refreshToken = jwt.sign(
    { sub: user.id, type: 'refresh' },
    process.env.JWT_REFRESH_SECRET,   // 用不同的密鑰!
    { expiresIn: '7d' }               // Refresh Token 較長:7 天
  );

  return { accessToken, refreshToken };
}

// ✅ JWT 驗證中間件
function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];  // Bearer <token>

  if (!token) {
    return res.status(401).json({ error: '未提供認證 Token' });
  }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET, {
      issuer: 'myshop.com.tw',
      audience: 'myshop-api'
    });

    // ⚠️ 重要:不要只信任 Token 裡的角色資訊
    // 要去資料庫確認使用者是否仍然有效
    req.userId = decoded.sub;
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token 已過期,請重新登入' });
    }
    return res.status(403).json({ error: '無效的 Token' });
  }
}

飛飛觀點:
JWT 最常見的錯誤就是「把太多東西塞進 Token 裡,然後完全信任 Token 的內容」。記住:JWT 是「簽名」不是「加密」——任何人都可以 decode 看到裡面的內容。絕對不要在 JWT 裡放密碼、信用卡號、或任何敏感資料。而且,Token 裡的角色和權限只是「快取」,真正做授權決策時,還是要到資料庫確認最新狀態。

3.2 Token 儲存安全

Token 存在哪裡,直接決定它的安全性:

儲存位置 XSS 風險 CSRF 風險 建議
<code>localStorage</code> ❌ 高(JS 可讀) ✅ 無 不建議存敏感 Token
<code>sessionStorage</code> ❌ 高(JS 可讀) ✅ 無 不建議存敏感 Token
<code>HttpOnly Cookie</code> ✅ 低(JS 不可讀) ❌ 需要 CSRF 防護 推薦方案
<code>HttpOnly Cookie</code> + SameSite ✅ 低 ✅ 低 最佳實踐
// ✅ 最佳實踐:用 HttpOnly + Secure + SameSite Cookie 存 Token
res.cookie('access_token', accessToken, {
  httpOnly: true,       // JavaScript 無法讀取
  secure: true,         // 只在 HTTPS 下傳送
  sameSite: 'Strict',   // 阻擋跨站請求(防 CSRF)
  maxAge: 15 * 60 * 1000,  // 15 分鐘
  path: '/api'          // 只在 API 路徑下傳送
});

四、授權模型:RBAC vs. ABAC,選哪個?

認證解決了「你是誰」,接下來要解決「你能做什麼」。這就是授權模型的工作。

4.1 RBAC(Role-Based Access Control):基於角色的存取控制

RBAC 是最常見的授權模型,核心邏輯是:使用者 → 角色 → 權限

用辦公室比喻:你的工作職稱(角色)決定你能進哪些房間(權限)。經理可以進會議室和辦公區,實習生只能進辦公區。

台灣電商平台的 RBAC 設計:

角色定義:
├── customer(一般會員)
│   ├── 瀏覽商品
│   ├── 下訂單
│   ├── 查看自己的訂單
│   └── 修改自己的個資
├── seller(賣家)
│   ├── 管理自己的商品(CRUD)
│   ├── 查看自己的訂單
│   ├── 回覆評論
│   └── 查看自己的銷售報表
├── customer_service(客服)
│   ├── 查看訂單(所有)
│   ├── 修改訂單狀態
│   └── 查看會員基本資料(遮蔽敏感欄位)
├── admin(管理員)
│   ├── 管理使用者帳號
│   ├── 管理系統設定
│   └── 查看所有報表
└── super_admin(超級管理員)
    └── 所有權限 + 管理管理員帳號

Node.js RBAC 實作:

// 權限定義:每個角色有哪些權限
const ROLE_PERMISSIONS = {
  customer: [
    'product:read',
    'order:create',
    'order:read:own',       // 只能讀自己的
    'profile:read:own',
    'profile:update:own'
  ],
  seller: [
    'product:read',
    'product:create:own',
    'product:update:own',
    'product:delete:own',
    'order:read:own',
    'review:reply:own',
    'report:read:own'
  ],
  customer_service: [
    'product:read',
    'order:read:all',
    'order:update:status',
    'user:read:basic'       // 只能看基本資料,敏感欄位遮蔽
  ],
  admin: [
    'user:read:all',
    'user:update:all',
    'user:delete:all',
    'system:config:read',
    'system:config:update',
    'report:read:all'
  ]
};

// 授權中間件
function authorize(...requiredPermissions) {
  return async (req, res, next) => {
    // ⚠️ 從資料庫即時查詢角色,不只依賴 Token
    const user = await db.query(
      'SELECT role, status FROM users WHERE id = $1',
      [req.userId]
    );

    if (!user || user.status !== 'active') {
      return res.status(403).json({ error: '帳號已停用或不存在' });
    }

    const userPermissions = ROLE_PERMISSIONS[user.role] || [];

    const hasPermission = requiredPermissions.every(
      perm => userPermissions.includes(perm)
    );

    if (!hasPermission) {
      await logSecurityEvent('AUTHORIZATION_DENIED', {
        userId: req.userId,
        role: user.role,
        requiredPermissions,
        path: req.path
      });
      return res.status(403).json({ error: '權限不足' });
    }

    req.userRole = user.role;
    next();
  };
}

// 使用範例
app.get('/api/orders',
  authenticateToken,
  authorize('order:read:all'),
  async (req, res) => { /* ... */ }
);

app.delete('/api/users/:id',
  authenticateToken,
  authorize('user:delete:all'),
  async (req, res) => { /* ... */ }
);

4.2 ABAC(Attribute-Based Access Control):基於屬性的存取控制

RBAC 簡單好用,但面對複雜的權限需求時會顯得力不從心。比如:

  • 「賣家只能在營業時間修改商品」——這涉及時間屬性
  • 「客服只能查看自己負責區域的訂單」——這涉及地理屬性
  • 「只有通過 KYC 驗證的使用者才能購買高單價商品」——這涉及使用者屬性

這些場景用 RBAC 很難表達,因為你需要為每種組合建立一個角色,角色會爆炸性增長(「台北區日班客服」、「台北區夜班客服」、「高雄區日班客服」…)。

ABAC 的解法是:不只看角色,還看環境、資源、動作的各種屬性來做決策。

ABAC 決策公式:
允許?= f(主體屬性, 資源屬性, 動作屬性, 環境屬性)

主體屬性:角色、部門、KYC 狀態、信用等級...
資源屬性:資料分類、擁有者、敏感等級...
動作屬性:讀取、寫入、刪除、匯出...
環境屬性:時間、IP 位址、裝置、地理位置...

Node.js ABAC 實作範例:

// ABAC 策略引擎
class PolicyEngine {
  constructor() {
    this.policies = [];
  }

  addPolicy(policy) {
    this.policies.push(policy);
  }

  evaluate(context) {
    // 預設拒絕(Deny by Default)
    let decision = 'DENY';

    for (const policy of this.policies) {
      const result = policy.evaluate(context);
      if (result === 'DENY') return 'DENY';   // 任何明確拒絕 → 直接拒絕
      if (result === 'ALLOW') decision = 'ALLOW';
    }

    return decision;
  }
}

// 定義策略
const policyEngine = new PolicyEngine();

// 策略一:賣家只能在營業時間(08:00-22:00)修改商品
policyEngine.addPolicy({
  name: 'seller-business-hours',
  evaluate: (ctx) => {
    if (ctx.subject.role !== 'seller') return 'NOT_APPLICABLE';
    if (ctx.action !== 'update' || ctx.resource.type !== 'product') return 'NOT_APPLICABLE';

    const hour = new Date().getHours();
    if (hour >= 8 && hour < 22) return 'ALLOW';

    return 'DENY';  // 非營業時間拒絕修改
  }
});

// 策略二:只有通過 KYC 的使用者才能購買 NT$50,000 以上的商品
policyEngine.addPolicy({
  name: 'high-value-purchase-kyc',
  evaluate: (ctx) => {
    if (ctx.action !== 'purchase') return 'NOT_APPLICABLE';
    if (ctx.resource.price < 50000) return 'NOT_APPLICABLE';

    if (ctx.subject.kycVerified) return 'ALLOW';
    return 'DENY';
  }
});

// 策略三:客服只能查看自己負責區域的訂單
policyEngine.addPolicy({
  name: 'cs-regional-access',
  evaluate: (ctx) => {
    if (ctx.subject.role !== 'customer_service') return 'NOT_APPLICABLE';
    if (ctx.action !== 'read' || ctx.resource.type !== 'order') return 'NOT_APPLICABLE';

    if (ctx.subject.region === ctx.resource.region) return 'ALLOW';
    return 'DENY';
  }
});

// 在 API 中使用
app.put('/api/products/:id',
  authenticateToken,
  async (req, res) => {
    const product = await getProduct(req.params.id);
    const user = await getUser(req.userId);

    const decision = policyEngine.evaluate({
      subject: { role: user.role, region: user.region, kycVerified: user.kycVerified },
      resource: { type: 'product', ownerId: product.sellerId, price: product.price },
      action: 'update',
      environment: { time: new Date(), ip: req.ip }
    });

    if (decision !== 'ALLOW') {
      return res.status(403).json({ error: '目前無法執行此操作' });
    }

    // 執行更新...
  }
);

4.3 RBAC vs. ABAC:怎麼選?

比較維度 RBAC ABAC
複雜度 低,容易理解和維護 高,策略多了容易混亂
彈性 低,角色固定 高,可依各種屬性動態決策
適合場景 角色明確、權限規則簡單 權限需要考慮多種條件
效能 快(查表即可) 較慢(每次要評估多條策略)
稽核 容易(誰有什麼角色一目了然) 複雜(需要追蹤哪條策略允許了什麼)
台灣常見場景 一般電商、CMS、內部管理系統 金融業、醫療、多租戶 SaaS

飛飛觀點:
90% 的系統用 RBAC 就夠了。不要為了追求技術的先進性而導入 ABAC——除非你的業務需求真的需要。如果你發現 RBAC 的角色數量開始爆炸(超過 15 個角色),那就是該考慮 ABAC 的時候了。一個務實的做法是:先用 RBAC 打底,對少數複雜場景補充 ABAC 策略。


五、從城堡到公路:Zero Trust 架構入門

5.1 傳統模型的問題:城堡與護城河

傳統的網路安全模型就像中世紀的城堡——一旦你通過護城河(防火牆)和城門(VPN),進入城堡內部後就自由了。這叫做邊界安全(Perimeter Security)

但這個模型有致命的問題:

  • 攻擊者一旦突破邊界(釣魚、VPN 漏洞、內鬼),就可以在內網橫向移動
  • 雲端和遠端工作讓「邊界」變得模糊——你的員工在咖啡廳用公共 Wi-Fi 連 VPN,邊界在哪?
  • 微服務架構下,服務之間的通訊不再只在「城堡內」

5.2 Zero Trust 的核心原則:「永不信任,持續驗證」

Zero Trust 不是一個產品,而是一種架構設計哲學。它的核心前提是:不管你在哪裡(公司內網還是咖啡廳)、不管你是誰(員工還是合作夥伴),每一次存取都要被驗證和授權。

用蓋房子比喻:傳統模型是「社區大門有門禁,但進了社區每戶都不鎖門」;Zero Trust 是「每一道門都有獨立的門鎖和監視器,每次進入都要刷卡」。

Zero Trust 的三大原則:

原則 說明 實踐方式
永不信任 不因為在內網就自動信任 所有請求都需要認證和授權
持續驗證 不是登入一次就永遠信任 持續評估裝置狀態、行為異常、Token 效期
最小權限 只給完成當下任務所需的最小權限 動態權限調整、Just-in-Time Access

5.3 在 Node.js 微服務中實踐 Zero Trust

// 微服務之間的 Zero Trust 驗證中間件
async function zeroTrustMiddleware(req, res, next) {
  // 1. 驗證呼叫者身份(服務對服務認證)
  const serviceToken = req.headers['x-service-token'];
  if (!verifyServiceToken(serviceToken)) {
    return res.status(401).json({ error: '未認證的服務呼叫' });
  }

  // 2. 檢查呼叫者是否有權限存取此端點
  const callerService = decodeServiceToken(serviceToken).serviceName;
  const allowedCallers = getEndpointPolicy(req.path, req.method);
  if (!allowedCallers.includes(callerService)) {
    await logSecurityEvent('ZERO_TRUST_DENIED', {
      caller: callerService,
      path: req.path,
      reason: 'SERVICE_NOT_ALLOWED'
    });
    return res.status(403).json({ error: '此服務無權存取此端點' });
  }

  // 3. 檢查呼叫頻率是否異常
  const callRate = await getServiceCallRate(callerService, req.path);
  if (callRate > THRESHOLD) {
    await logSecurityEvent('ZERO_TRUST_ANOMALY', {
      caller: callerService,
      callRate,
      threshold: THRESHOLD
    });
    return res.status(429).json({ error: '呼叫頻率異常' });
  }

  // 4. 如果請求攜帶使用者 Token,也要驗證
  if (req.headers['authorization']) {
    try {
      const userToken = req.headers['authorization'].split(' ')[1];
      const decoded = jwt.verify(userToken, process.env.JWT_SECRET);
      req.userId = decoded.sub;
    } catch (err) {
      return res.status(401).json({ error: '使用者 Token 無效' });
    }
  }

  // 5. 記錄所有存取行為(稽核日誌)
  await logAccess({
    caller: callerService,
    userId: req.userId || 'system',
    path: req.path,
    method: req.method,
    timestamp: new Date()
  });

  next();
}

// 服務間的端點存取策略
function getEndpointPolicy(path, method) {
  const policies = {
    'GET /api/users/:id': ['order-service', 'notification-service'],
    'POST /api/payments': ['order-service'],
    'PUT /api/orders/:id/status': ['delivery-service', 'payment-service'],
    // 每個端點明確列出哪些服務可以呼叫
  };

  return policies[<code class="kb-btn">${method} ${path}</code>] || [];
}

5.4 Zero Trust 落地路線圖

不可能一夜之間變成 Zero Trust。以下是分階段導入的務實建議:

階段一:盤點與基礎建設(1-3 個月)

  • 盤點所有系統、服務、使用者、資料流
  • 為所有內部服務加上認證(不再依賴「內網就安全」)
  • 導入集中式身份管理(如 Keycloak、Auth0)

階段二:強化存取控制(3-6 個月)

  • 實施最小權限原則,盤點並收回過大的權限
  • 所有管理員帳號啟用 MFA
  • 導入 API Gateway 統一管理存取

階段三:持續監控與動態信任(6-12 個月)

  • 建立行為異常偵測機制
  • 導入裝置信任評估(裝置是否更新、是否有防毒)
  • 實施 Just-in-Time 權限(需要時申請,用完自動撤銷)

六、實戰案例:台灣線上訂餐平台的認證與授權設計

讓我們用一個完整的案例,把本篇所有觀念串起來。

場景

你的團隊正在為一間台灣連鎖餐飲集團設計線上訂餐平台。系統有四種角色:消費者、店家、外送員、總部管理員。

認證架構設計

消費者:Email/密碼 + 手機 OTP(首次登入新裝置時)
        支援 Line Login 社群登入(OAuth 2.0)
        支援 Passkey(可選)

店家:  Email/密碼 + TOTP(Google Authenticator)
        必須啟用 MFA

外送員:手機號碼 + 簡訊 OTP(每次登入)
        GPS 位置驗證(確認在營業區域內)

管理員:SSO(公司 AD 整合)+ FIDO2 硬體金鑰
        IP 白名單(只能從辦公室網路登入)

授權架構設計(RBAC + 部分 ABAC)

// RBAC 基礎權限
const ROLE_PERMISSIONS = {
  customer: [
    'menu:read',
    'order:create',
    'order:read:own',
    'order:cancel:own',         // 只能取消自己的訂單
    'review:create',
    'payment:create:own'
  ],
  store: [
    'menu:manage:own',          // 只能管理自家菜單
    'order:read:own_store',     // 只能看到自家的訂單
    'order:update:status:own',  // 只能更新自家訂單狀態
    'review:reply:own'
  ],
  delivery: [
    'order:read:assigned',      // 只能看被指派的訂單
    'order:update:delivery',    // 只能更新配送狀態
    'location:update:own'       // 只能更新自己的位置
  ],
  admin: [
    'user:manage:all',
    'store:manage:all',
    'order:read:all',
    'report:read:all',
    'system:config:manage'
  ]
};

// ABAC 補充策略
const abacPolicies = [
  // 消費者只能在訂單建立後 5 分鐘內取消
  {
    name: 'order-cancel-time-limit',
    evaluate: (ctx) => {
      if (ctx.action !== 'cancel' || ctx.resource.type !== 'order') return 'NOT_APPLICABLE';
      const minutesSinceCreated = (Date.now() - ctx.resource.createdAt) / 60000;
      return minutesSinceCreated <= 5 ? 'ALLOW' : 'DENY';
    }
  },
  // 店家只能在營業時間接受新訂單
  {
    name: 'store-business-hours',
    evaluate: (ctx) => {
      if (ctx.subject.role !== 'store') return 'NOT_APPLICABLE';
      if (ctx.action !== 'accept') return 'NOT_APPLICABLE';
      const now = new Date();
      const hour = now.getHours();
      return (hour >= ctx.subject.openHour && hour < ctx.subject.closeHour)
        ? 'ALLOW' : 'DENY';
    }
  },
  // 外送員只能在被指派的配送區域內操作
  {
    name: 'delivery-zone-restriction',
    evaluate: (ctx) => {
      if (ctx.subject.role !== 'delivery') return 'NOT_APPLICABLE';
      return ctx.subject.assignedZone === ctx.resource.deliveryZone
        ? 'ALLOW' : 'DENY';
    }
  }
];

Token 策略

Access Token:  15 分鐘,HttpOnly Cookie,存使用者 ID 和角色
Refresh Token: 消費者 30 天、店家 7 天、管理員 1 天
密碼重設 Token:30 分鐘,一次性使用
Email 驗證 Token:24 小時,一次性使用

所有 Token 簽發時記錄:IP、User-Agent、裝置指紋
Token 被使用時,如果 IP 或裝置指紋與簽發時不同 → 觸發 MFA 重新驗證

七、認證與授權設計 Checklist

以下是一份可以直接用於專案的檢核清單:

## 認證設計 Checklist

### 密碼安全
- [ ] 密碼使用 Argon2id 或 bcrypt 雜湊(不是 MD5 / SHA-256)
- [ ] 最低密碼長度 ≥ 12 碼
- [ ] 不限制密碼字元類型(允許 Unicode)
- [ ] 登入失敗 5 次鎖定帳號 15 分鐘
- [ ] 錯誤訊息統一為「帳號或密碼錯誤」
- [ ] 密碼重設連結 30 分鐘過期,一次性使用
- [ ] 密碼重設後撤銷所有已簽發的 Token

### 多因素認證
- [ ] 管理員帳號強制啟用 MFA
- [ ] 敏感操作(改密碼、改 Email、大額交易)要求 MFA
- [ ] 新裝置首次登入要求額外驗證
- [ ] 提供多種 MFA 選項(不只有簡訊 OTP)

### Token 管理
- [ ] Access Token 效期 ≤ 30 分鐘
- [ ] Refresh Token 有適當的效期
- [ ] Token 存在 HttpOnly + Secure + SameSite Cookie
- [ ] JWT 不包含敏感資料(密碼、信用卡號)
- [ ] 有 Token 撤銷機制(登出、密碼變更時)

### 登入安全
- [ ] 所有登入行為(成功/失敗)都有日誌記錄
- [ ] 日誌中不包含密碼(含雜湊值)
- [ ] 有異常登入偵測(異地登入、短時間大量嘗試)
- [ ] 社群登入(OAuth)有 state 參數防 CSRF

## 授權設計 Checklist

### 存取控制
- [ ] 預設拒絕(Deny by Default),明確授予權限
- [ ] 每個 API 端點都有授權檢查(不依賴前端隱藏)
- [ ] 授權決策在伺服器端,不信任前端傳來的角色資訊
- [ ] 資源存取檢查所有權(使用者只能存取自己的資料)
- [ ] 權限變更即時生效(不等 Token 過期)

### RBAC 設計
- [ ] 角色定義清楚,職責不重疊
- [ ] 管理員權限分級(不是只有一個 admin 角色)
- [ ] 角色指派有審核機制(不能自己升級自己的角色)
- [ ] 定期盤點權限,移除不再需要的角色指派

### 日誌與稽核
- [ ] 所有授權失敗都記錄日誌
- [ ] 權限變更(角色指派、權限調整)都有稽核紀錄
- [ ] 異常存取行為有即時告警

八、常見問題 FAQ

Q1:JWT 和 Session 我到底該選哪個?

如果你的架構是傳統的 Server-Side Rendering(如 Express + EJS),或者是單體架構,用 Session 就好——簡單、撤銷方便、不需要處理 Token 過期的複雜邏輯。

如果你的架構是 SPA + API、行動 App、或微服務,JWT 是更自然的選擇——無狀態、適合分散式系統、跨服務認證方便。

但記住:不管選哪個,安全原則都一樣——短效期、安全儲存、可撤銷。

Q2:我的系統很小,需要考慮 Zero Trust 嗎?

Zero Trust 的完整架構確實比較適合中大型組織。但它的核心精神——「每次存取都驗證」——不管系統多小都適用。

對小團隊來說,至少做到以下三點:

  1. 每個 API 端點都有認證檢查(不要有「匿名可存取的內部 API」)
  2. 管理員帳號啟用 MFA
  3. 不要因為「在內網」就省略認證

這三件事不需要買任何工具,花一天就能做到。

Q3:RBAC 的角色太多了怎麼辦?

角色爆炸通常有兩個原因:

  1. 把「權限」當成「角色」:比如你建了「可匯出報表的客服」、「不可匯出報表的客服」兩個角色,其實應該是一個「客服」角色加上「報表匯出」這個獨立權限。

  2. 把「組織結構」塞進角色:比如「台北區主管」、「高雄區主管」。組織結構應該用 ABAC 的屬性來處理,不是建新角色。

如果角色超過 15 個,建議重新審視角色設計,看看是否需要引入 ABAC 來處理動態條件。

Q4:這些跟台灣的法規有什麼關係?

台灣個資法要求「適當安全維護措施」,認證與授權就是最核心的安全措施之一。具體來說:

  • 個資法 §27:要有適當的存取控制機制(就是授權)
  • 金融資安行動方案:要求電子交易必須有多因素驗證
  • 資通安全管控指引:上市櫃公司需要有帳號管理與存取控制機制

做好認證與授權,不只是安全最佳實踐,也是法規遵循的基本要求。


九、結語:認證是門鎖,授權是房間鑰匙,Zero Trust 是每道門都上鎖

回到蓋房子的比喻。一棟安全的建築,不會只在大門口放一個保全就覺得萬事大吉。每一層樓有門禁、每一間辦公室有獨立的鑰匙、金庫還需要雙人同時開啟——這就是認證、授權、Zero Trust 在建築中的體現。

軟體系統也是一樣。認證確認「你是誰」,授權決定「你能做什麼」,Zero Trust 確保「每一次存取都經過驗證」。這三者不是三個獨立的系統,而是層層遞進的安全架構。

很多開發者會說:「這些東西好複雜,我們先上線再說。」但認證和授權是系統的骨架——就像承重牆一樣,事後要改的成本極高。如果你在設計階段就把認證和授權的架構想清楚,後續的開發反而會更順暢,因為你不需要一直回頭修補。

就像這個系列一直在說的:安全不是恐懼,而是創造的基礎。 堅固的認證與授權架構,不是限制你的創造力,而是讓你可以安心地在上面建構更豐富、更有價值的功能。

下一篇,我們會進入安全設計的另一個重要主題——API 安全設計:保護你的資料入口。認證授權的架構有了,接下來要看看怎麼把 API 這個對外大門守好。


延伸閱讀

[安全設計] 002 安全設計原則實戰指南:最小權限、縱深防禦、預設安全|七大原則與 Node.js 程式碼範例

你可以在上線前跑一百次滲透測試,
但如果系統在設計時就沒有安全的骨架,那不過是在紙糊的牆上貼防彈貼紙。


一、安全設計原則是什麼?為什麼你應該在寫程式前就搞懂它?

延續 SSDLC 的「蓋房子」比喻——如果說安全需求是確認「這棟房子要防震、防火、防盜」,威脅建模是找出「小偷可能從哪裡闖入」,那麼安全設計原則就是建築師的設計哲學:承重牆要放哪裡、逃生動線怎麼規劃、哪些地方要用防火建材。

安全設計原則不是一份 Checklist,而是一套思維框架。它告訴你:在面對各種設計決策時,怎麼選擇才能讓系統「先天體質好」,而不是靠後天吃補藥。

在 SSDLC 的七大階段中,安全設計原則屬於階段二:安全設計(Design)。上一篇我們學了用 STRIDE 做威脅建模,找出「威脅從哪來」;這一篇,我們要學的是「怎麼用設計把威脅擋在門外」。

飛飛觀點:
很多開發者把安全當成「功能做完再補上去的東西」。但安全設計原則告訴我們:安全不是外掛,是地基。地基歪了,上面蓋再多防禦工事都沒用。


二、七大安全設計原則:建築師的防禦哲學

這七大原則源自 Saltzer & Schroeder 在 1975 年提出的經典理論,經過近五十年的演進,至今仍是所有安全架構的基石。別被年份嚇到——這些原則就像牛頓力學一樣,基礎到你每天都在用,只是可能沒意識到。

原則一:最小權限(Least Privilege)

蓋房子比喻:水電工只需要進機電室的鑰匙,不需要拿到整棟大樓的萬能鑰匙。

最小權限的意思是:每個使用者、程式、或系統元件,只應該擁有完成其工作所需的最少權限,不多也不少。

為什麼重要?

想像你開了一間公司,每個員工都有金庫密碼、伺服器 root 權限、老闆的信用卡號碼。某天有個實習生的電腦中毒了——恭喜,攻擊者直接拿到你全公司的鑰匙。

台灣實戰場景:電商後台系統

角色 不好的做法(權限過大) 好的做法(最小權限)
客服人員 可以看到所有會員的完整個資和信用卡號 只能看到處理中的工單相關會員資訊,信用卡號遮蔽顯示
行銷人員 可以直接 SQL 查詢會員資料庫 只能透過報表系統查看匿名化的統計數據
倉儲人員 擁有後台管理員帳號 只能操作出貨管理模組,無法進入財務或會員模組
開發人員 Production 資料庫有 DELETE 權限 Production 只有 SELECT 權限,刪除需走申請流程

程式碼範例:Node.js 的最小權限實踐

// ❌ 不好的做法:一個萬能 API Key 存取所有服務
const client = new AWS.S3({
  accessKeyId: process.env.AWS_MASTER_KEY,  // 有全部 S3 權限
  secretAccessKey: process.env.AWS_MASTER_SECRET
});

// ✅ 好的做法:每個服務用獨立的 IAM 角色,只給需要的權限
const uploadClient = new AWS.S3({
  // 這個角色只能 PutObject 到特定 bucket
  // 不能刪除、不能列出其他 bucket、不能修改權限
  accessKeyId: process.env.UPLOAD_SERVICE_KEY,
  secretAccessKey: process.env.UPLOAD_SERVICE_SECRET
});
// ❌ 不好的做法:資料庫連線用 root
const db = new Pool({
  user: 'root',  // 可以 DROP DATABASE!
  password: process.env.DB_ROOT_PASSWORD
});

// ✅ 好的做法:應用程式用專屬帳號,只開需要的權限
const db = new Pool({
  user: 'app_readonly',  // 只有 SELECT 權限
  password: process.env.DB_APP_PASSWORD
});

// 需要寫入時,用另一個連線
const dbWriter = new Pool({
  user: 'app_writer',  // 只有 INSERT/UPDATE 權限,沒有 DELETE
  password: process.env.DB_WRITER_PASSWORD
});

原則二:縱深防禦(Defense in Depth)

蓋房子比喻:你家不會只裝一道門鎖就安心——你會有大門門鎖、門口監視器、社區保全、室內保險箱。攻擊者要突破每一層才能得逞。

縱深防禦的概念是:不要把所有雞蛋放在同一個籃子裡。你應該設置多層防禦,即使其中一層被突破,後面還有其他層可以擋住攻擊。

為什麼重要?

沒有任何單一安全措施是完美的。WAF 可能被繞過、密碼可能被破解、員工可能被釣魚。縱深防禦確保攻擊者必須連續突破多道關卡才能造成真正的傷害。

台灣實戰場景:線上銀行轉帳功能的縱深防禦

第一層【網路層】    → WAF 過濾惡意請求 + TLS 加密傳輸
第二層【身份驗證】  → 帳號密碼 + 手機 OTP 雙因素驗證
第三層【授權檢查】  → 檢查此帳號是否有轉帳權限 + 單日額度限制
第四層【交易驗證】  → 大額轉帳(> NT$50,000)需額外簡訊確認
第五層【輸入驗證】  → 檢查轉帳金額、帳號格式、防止 SQL Injection
第六層【監控告警】  → 異常轉帳行為即時通知(如深夜大額、新收款帳號)
第七層【稽核日誌】  → 所有交易完整記錄,供事後追查

即使駭客突破了 WAF,他還需要通過雙因素驗證;即使他拿到了密碼,大額轉帳還有額外確認;即使交易真的被執行了,監控系統也會即時告警,稽核日誌能幫助追回損失。

程式碼範例:API 端點的多層防禦

// 縱深防禦的 Express middleware 堆疊
const express = require('express');
const app = express();

// 第一層:速率限制(Rate Limiting)
const rateLimit = require('express-rate-limit');
app.use('/api/', rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 分鐘
  max: 100,                    // 每個 IP 最多 100 次
  message: { error: '請求過於頻繁,請稍後再試' }
}));

// 第二層:輸入驗證
const { body, validationResult } = require('express-validator');

// 第三層:身份驗證(JWT 驗證)
const authenticateToken = require('./middleware/auth');

// 第四層:授權檢查(角色權限)
const authorize = require('./middleware/authorize');

// 第五層:業務邏輯層的額外驗證
app.post('/api/transfer',
  body('amount').isFloat({ min: 1, max: 1000000 }),  // 輸入驗證
  body('toAccount').matches(/^\d{12,16}$/),           // 格式檢查
  authenticateToken,                                    // 身份驗證
  authorize('transfer'),                                // 授權檢查
  async (req, res) => {
    // 第六層:業務規則檢查
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }

    const { amount, toAccount } = req.body;

    // 檢查單日累計轉帳額度
    const dailyTotal = await getDailyTransferTotal(req.user.id);
    if (dailyTotal + amount > 500000) {
      await logSecurityEvent('TRANSFER_LIMIT_EXCEEDED', req.user.id);
      return res.status(403).json({ error: '已超過單日轉帳上限' });
    }

    // 大額轉帳需 OTP 確認
    if (amount > 50000) {
      const otpValid = await verifyOTP(req.user.id, req.body.otp);
      if (!otpValid) {
        return res.status(403).json({ error: '大額轉帳需簡訊驗證' });
      }
    }

    // 第七層:記錄稽核日誌
    await auditLog({
      action: 'TRANSFER',
      userId: req.user.id,
      amount,
      toAccount: maskAccount(toAccount),  // 日誌中遮蔽帳號
      ip: req.ip,
      timestamp: new Date()
    });

    // 執行轉帳...
  }
);

原則三:預設安全(Secure by Default)

蓋房子比喻:新房子交屋時,門窗應該是「鎖上」的狀態,而不是「敞開」讓住戶自己去鎖。

預設安全的意思是:系統在未經特別設定的情況下,就應該處於安全狀態。使用者需要主動「打開」功能,而不是主動「關閉」風險。

為什麼重要?

大多數使用者(包括開發者)不會去改預設設定。如果你的框架預設開啟 Debug 模式、預設允許所有來源的 CORS、預設不加密 Cookie——那大部分上線的系統都會帶著這些風險。

常見的「預設不安全」 vs.「預設安全」

設定項目 預設不安全(常見錯誤) 預設安全(正確做法)
Debug 模式 預設開啟,顯示完整錯誤堆疊 預設關閉,只顯示友善錯誤訊息
CORS 設定 <code>Access-Control-Allow-Origin: *</code> 只允許特定白名單域名
Cookie 沒設 <code>HttpOnly</code>、<code>Secure</code>、<code>SameSite</code> 預設 <code>HttpOnly; Secure; SameSite=Strict</code>
密碼政策 無限制,<code>123456</code> 也能過 至少 12 碼,含大小寫、數字、特殊符號
檔案上傳 允許所有檔案類型 只允許白名單內的類型(jpg, png, pdf)
API 回應 回傳所有欄位,包含內部 ID 和敏感資訊 只回傳必要欄位,過濾敏感資訊
帳號註冊 Email 不用驗證即可使用 必須通過 Email 驗證才能啟用帳號

程式碼範例:Express.js 預設安全配置

const express = require('express');
const helmet = require('helmet');
const cors = require('cors');

const app = express();

// ✅ 預設安全的 HTTP 標頭
app.use(helmet());  // 自動設定多個安全標頭

// ✅ 嚴格的 CORS 白名單
app.use(cors({
  origin: ['https://www.myshop.com.tw', 'https://admin.myshop.com.tw'],
  methods: ['GET', 'POST'],  // 只允許需要的 HTTP 方法
  credentials: true
}));

// ✅ 安全的 Cookie 預設值
app.use(require('cookie-session')({
  name: 'session',
  httpOnly: true,     // JavaScript 無法存取
  secure: true,       // 只透過 HTTPS 傳送
  sameSite: 'strict', // 防止 CSRF
  maxAge: 30 * 60 * 1000  // 30 分鐘過期
}));

// ✅ 預設關閉 Debug 資訊
if (process.env.NODE_ENV === 'production') {
  app.set('trust proxy', 1);
  // 錯誤處理不揭露內部資訊
  app.use((err, req, res, next) => {
    console.error(err.stack);  // 只記到伺服器日誌
    res.status(500).json({ error: '系統發生錯誤,請稍後再試' });
    // 絕不回傳 err.stack 或 err.message 給客戶端
  });
}

飛飛觀點:
預設安全的精髓是「懶人也安全」。好的框架和好的設計,應該讓開發者不用刻意做什麼就已經是安全的。需要特別努力的應該是「打開不安全的功能」,而不是「關閉不安全的功能」。


原則四:開放設計(Open Design)

蓋房子比喻:好的保全系統不是因為小偷不知道你裝了什麼牌子的鎖才安全,而是因為鎖本身夠堅固——就算小偷知道廠牌型號,還是打不開。

開放設計原則主張:系統的安全性不應該依賴於設計或實作的保密性(也就是不能靠 "Security through Obscurity")。安全應該來自於良好的設計本身,而非隱藏設計細節。

為什麼重要?

密碼學的 Kerckhoffs 原則早在 1883 年就說過:加密系統的安全性不應該依賴於演算法的保密,而應該依賴於金鑰的保密。同樣的道理,你的系統架構可以被公開審查,安全性來自於金鑰、密碼、Token 等「秘密」的保護,而非隱藏你用了什麼技術。

實際案例

不好的做法(靠隱藏) 好的做法(開放設計)
用自己發明的加密演算法 使用公開審查過的 AES-256
API 路徑用亂碼命名(<code>/x7k9m</code>)當作「安全」 正常命名(<code>/api/orders</code>)+ 嚴格的認證與授權
把 Admin 頁面藏在奇怪的 URL 就覺得沒人找得到 Admin 頁面正常路徑 + 強制登入 + IP 白名單 + MFA
原始碼混淆後就覺得不會被逆向工程 即使原始碼被看到,安全機制仍然有效
// ❌ Security through Obscurity(靠隱藏的假安全)
// 「把 Admin API 藏在奇怪路徑,應該沒人找得到吧」
app.post('/x7k9m2q/do-admin-stuff', (req, res) => {
  // 沒有任何身份驗證...
  deleteAllUsers();  // 😱
});

// ✅ Open Design(開放設計的真安全)
// 路徑很明確,但有層層保護
app.post('/api/admin/users/bulk-delete',
  authenticateToken,               // 驗證身份
  authorize('admin'),              // 確認是管理員
  requireMFA,                      // 強制多因素驗證
  validateIPWhitelist,             // IP 白名單
  rateLimitAdmin,                  // 速率限制
  auditLog('BULK_DELETE_USERS'),   // 稽核記錄
  async (req, res) => {
    // 即使攻擊者知道這個路徑,也過不了前面的關卡
  }
);

原則五:失敗安全(Fail Secure / Fail Safe)

蓋房子比喻:大樓停電時,門禁系統應該讓門保持「鎖上」狀態(除非是消防逃生門),而不是所有門都自動打開。

失敗安全的意思是:當系統發生錯誤、例外、或不預期的狀況時,應該回到安全的狀態,而不是開放的狀態。

為什麼重要?

系統一定會出錯。網路會斷、資料庫會掛、第三方服務會 timeout。關鍵是:出錯的時候,你的系統是「寧可拒絕服務」還是「寧可放行一切」?

台灣實戰場景

情境 失敗不安全(危險) 失敗安全(正確)
認證伺服器掛了 跳過認證,直接放行 拒絕所有請求,返回 503
權限檢查出錯 預設給予完整權限 預設拒絕存取
信用卡驗證 timeout 當作驗證成功,先出貨 當作驗證失敗,要求重試
WAF 發生錯誤 關閉 WAF,直接放行流量 阻擋所有流量,通知維運團隊
日誌系統滿了 停止記錄但繼續服務 發出告警,視情況限制服務

程式碼範例

// ❌ 失敗不安全:認證服務出錯時放行
async function checkAuthorization(userId, resource) {
  try {
    const result = await authService.check(userId, resource);
    return result.allowed;
  } catch (error) {
    console.log('Auth service error, allowing access...');
    return true;  // 😱 出錯就放行!
  }
}

// ✅ 失敗安全:出錯時預設拒絕
async function checkAuthorization(userId, resource) {
  try {
    const result = await authService.check(userId, resource);
    return result.allowed;
  } catch (error) {
    console.error('Auth service error:', error.message);
    await alertOpsTeam('AUTH_SERVICE_DOWN', { userId, resource });
    return false;  // 出錯就拒絕,寧可多擋不可少擋
  }
}
// ✅ 進階:加入降級策略
async function checkAuthorization(userId, resource) {
  try {
    return await authService.check(userId, resource);
  } catch (error) {
    // 第一層降級:嘗試本地快取
    const cachedResult = await localCache.get(<code class="kb-btn">auth:${userId}:${resource}</code>);
    if (cachedResult && cachedResult.timestamp > Date.now() - 300000) {
      console.warn('Using cached auth result (5 min validity)');
      return cachedResult.allowed;
    }

    // 第二層降級:拒絕並通知
    await alertOpsTeam('AUTH_SERVICE_DOWN', { userId, resource });
    return false;  // 最終還是拒絕
  }
}

原則六:完整調解(Complete Mediation)

蓋房子比喻:每次進出大樓都要刷門禁卡,不能因為「剛剛才刷過」就讓人免刷直接進——萬一門禁卡在這之間被偷了呢?

完整調解要求:每一次資源存取都必須經過授權檢查,不能因為之前檢查過就跳過。

為什麼重要?

很多系統只在「第一次請求」時做權限檢查,之後就靠 Session 或快取來放行。但使用者的權限可能在會話期間被變更(例如被降權、被停用),如果沒有每次都檢查,就可能出現「已被停權的帳號還能繼續操作」的情況。

程式碼範例

// ❌ 不好的做法:只在登入時檢查一次角色
app.post('/login', async (req, res) => {
  const user = await authenticate(req.body);
  const token = jwt.sign({ 
    userId: user.id, 
    role: user.role  // 角色寫死在 Token 裡
  }, SECRET, { expiresIn: '24h' });
  res.json({ token });
});

// 之後的 24 小時內,即使 user 被降權或停用,
// Token 裡的 role 不會改變...

// ✅ 好的做法:每次請求都重新查詢即時權限
const checkPermission = async (req, res, next) => {
  const tokenData = jwt.verify(req.headers.authorization, SECRET);

  // 每次都從資料庫查詢最新的角色和狀態
  const user = await db.query(
    'SELECT role, status FROM users WHERE id = $1',
    [tokenData.userId]
  );

  if (!user || user.status !== 'active') {
    return res.status(403).json({ error: '帳號已停用' });
  }

  // 用即時角色做權限判斷,不是 Token 裡的舊角色
  req.user = { id: tokenData.userId, role: user.role };
  next();
};

原則七:最小共享機制(Least Common Mechanism)

蓋房子比喻:住戶的信箱應該各自獨立,不應該共用一個大信箱——否則你的信件可能被鄰居拿走。

最小共享機制的原則是:不同使用者或不同模組之間,應該盡量減少共享的資源和機制。共享的東西越多,一個環節出問題影響的範圍就越大。

為什麼重要?

共享資源是安全事件的放大器。如果所有微服務共用同一組資料庫帳號,其中一個服務被入侵,攻擊者就拿到了所有服務的資料。如果所有租戶的資料放在同一張表,一個 SQL Injection 就可能洩漏所有租戶的資料。

台灣實戰場景:SaaS 平台的多租戶隔離

// ❌ 不好的做法:所有租戶共用一張表,靠 tenant_id 欄位區分
// 萬一有一個查詢忘了加 WHERE tenant_id = ?,就是全部外洩
app.get('/api/orders', async (req, res) => {
  // 開發者可能忘記加 tenant 過濾...
  const orders = await db.query('SELECT * FROM orders');  // 😱 全租戶資料外洩
  res.json(orders);
});

// ✅ 好的做法:每個租戶用獨立的 Schema 或資料庫
app.get('/api/orders', async (req, res) => {
  const tenantDb = getTenantConnection(req.tenant.id);
  // 即使忘了加過濾條件,也只能查到該租戶自己的資料
  const orders = await tenantDb.query('SELECT * FROM orders');
  res.json(orders);
});
// ✅ 如果必須共用資料表,用 Row-Level Security(RLS)作為安全網
// PostgreSQL RLS 設定範例
/*
  CREATE POLICY tenant_isolation ON orders
    USING (tenant_id = current_setting('app.current_tenant')::uuid);

  ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
*/

// 每次查詢前設定 tenant context
app.use(async (req, res, next) => {
  await db.query(
    "SET app.current_tenant = $1",
    [req.tenant.id]
  );
  next();
});

三、七大原則速查表

原則 一句話解釋 蓋房子比喻 最常見的違反情境
最小權限 只給剛剛好的權限 水電工不需要萬能鑰匙 所有服務用同一個 root 帳號
縱深防禦 多層防禦,不靠單一措施 門鎖 + 監視器 + 保全 + 保險箱 只靠 WAF 就覺得安全了
預設安全 出廠就是安全的 新房子交屋門窗是鎖的 Debug 模式預設開啟上線
開放設計 安全不靠隱藏 好鎖不怕你知道廠牌 用自創加密演算法
失敗安全 出錯時回到安全狀態 停電時門保持上鎖 認證服務掛了就跳過驗證
完整調解 每次存取都要檢查 每次進大樓都要刷卡 權限寫死在 Token 裡不更新
最小共享 減少共用資源 住戶信箱各自獨立 所有租戶共用一組 DB 帳號

四、實戰案例:用七大原則設計台灣線上訂餐平台

讓我們用一個完整的案例,示範如何在設計階段就把七大原則融入系統架構。

場景描述

你的團隊要為一間連鎖餐飲集團設計線上訂餐平台,功能包含:顧客瀏覽菜單與下單、店家管理訂單與菜單、外送員接單與回報配送狀態、總部查看營運報表。

套用七大原則

1. 最小權限 → 角色與權限設計

顧客:   瀏覽菜單(R) + 建立訂單(C) + 查看自己的訂單(R)
店家:   管理自家菜單(CRUD) + 查看自家訂單(R) + 更新訂單狀態(U)
外送員: 查看待接訂單(R) + 更新配送狀態(U)
總部:   查看報表(R) + 管理店家帳號(CRUD)

❌ 店家不能看到其他店的訂單
❌ 外送員不能修改訂單金額
❌ 總部不能直接操作訂單

2. 縱深防禦 → 訂單流程的多層保護

【下單】 → 前端輸入驗證 → API Gateway 速率限制 → JWT 身份驗證
       → 後端輸入驗證 → 庫存檢查 → 價格伺服器端計算(不信任前端金額)
       → 金流驗證 → 訂單寫入 → 稽核日誌

3. 預設安全 → 系統出廠設定

// 新開的店家帳號,預設功能是限縮的
const DEFAULT_STORE_CONFIG = {
  maxOrderPerHour: 50,        // 預設限制每小時訂單量
  allowCashOnDelivery: false, // 預設不開放貨到付款(風險較高)
  autoAcceptOrder: false,     // 預設需手動接單
  apiRateLimit: 100,          // API 每分鐘上限
  allowBulkExport: false      // 預設不允許批量匯出顧客資料
};
// 需要的功能,由總部審核後逐一開啟

4. 開放設計 → 不靠隱藏保安全

✅ API 文件公開(Swagger/OpenAPI),但每個端點都有認證
✅ 使用標準加密(AES-256, bcrypt),不自創演算法
✅ 即使競爭對手看到 API 規格,也無法繞過認證機制

5. 失敗安全 → 金流異常處理

async function processPayment(order) {
  try {
    const result = await paymentGateway.charge(order.amount);
    if (result.status === 'success') {
      await updateOrderStatus(order.id, 'PAID');
      return { success: true };
    }
  } catch (error) {
    // 金流異常 → 訂單保持「未付款」,不會出餐
    await updateOrderStatus(order.id, 'PAYMENT_FAILED');
    await notifyCustomer(order.customerId, '付款失敗,請重試');
    await logPaymentError(order.id, error);
    return { success: false, error: '付款處理失敗' };
    // 絕不在金流失敗時當作成功
  }
}

6. 完整調解 → 每次 API 呼叫都驗證

// 中介軟體:每個請求都查即時權限
app.use('/api/store/:storeId/*', async (req, res, next) => {
  const user = await getCurrentUser(req);

  // 即時查詢:這個人現在是否仍有權限操作這間店?
  const hasAccess = await db.query(
    `SELECT 1 FROM store_members 
     WHERE user_id = $1 AND store_id = $2 AND status = 'active'`,
    [user.id, req.params.storeId]
  );

  if (!hasAccess) {
    return res.status(403).json({ error: '無權存取此店家資料' });
  }
  next();
});

7. 最小共享 → 店家資料隔離

// 每間店家的訂單、菜單、顧客資料都用 store_id 嚴格隔離
// 搭配 PostgreSQL RLS,即使程式碼有 bug 也不會跨店洩漏
/*
  CREATE POLICY store_isolation ON orders
    USING (store_id = current_setting('app.current_store')::uuid);
*/

五、安全設計審查 Checklist

設計完系統架構後,用這份 Checklist 確認七大原則是否都有照顧到:

## 安全設計原則審查 Checklist

### 最小權限
- [ ] 每個角色的權限是否有明確定義?
- [ ] 是否存在「萬能帳號」或「共用帳號」?
- [ ] 服務間的 API Key 是否各自獨立、權限最小化?
- [ ] 資料庫帳號是否依讀/寫分離?

### 縱深防禦
- [ ] 從使用者到資料庫,是否有至少三層以上的安全檢查?
- [ ] 如果其中一層被突破,是否還有其他層能擋住?
- [ ] 是否有監控和告警機制作為最後一道防線?

### 預設安全
- [ ] 系統預設設定是否已是安全狀態?
- [ ] Debug 模式、詳細錯誤訊息是否在 Production 確認關閉?
- [ ] Cookie、CORS、HTTP 標頭的預設值是否安全?

### 開放設計
- [ ] 安全機制是否不依賴隱藏路徑或自創演算法?
- [ ] 即使攻擊者知道系統架構,安全機制是否仍然有效?

### 失敗安全
- [ ] 認證/授權服務失敗時,系統是否預設拒絕?
- [ ] 金流/支付異常時,是否回到安全狀態(不出貨、不扣款)?
- [ ] 外部服務 timeout 時,是否有合理的降級策略?

### 完整調解
- [ ] 每次 API 呼叫是否都有即時的權限檢查?
- [ ] 使用者被停權後,現有的 Session/Token 是否會失效?

### 最小共享
- [ ] 不同租戶/使用者的資料是否有適當隔離?
- [ ] 不同服務是否使用各自獨立的帳號和權限?
- [ ] 是否有機制防止跨租戶的資料存取?

六、團隊落地建議

建議一:在設計文件中加入「安全設計決策紀錄」

每次做架構設計時,在設計文件中加一個段落,記錄你應用了哪些安全設計原則,以及為什麼這樣設計。

## 安全設計決策

### 決策 1:訂單查詢 API 採用即時權限檢查(完整調解原則)
- **決策**:每次查詢訂單都從 DB 即時查詢權限,不用 Token 中的靜態角色
- **原因**:店長可能臨時被撤換,需要即時生效
- **代價**:每次 API 呼叫多一次 DB 查詢
- **緩解**:用 Redis 快取權限,TTL 5 分鐘

### 決策 2:金流失敗時訂單保持未確認狀態(失敗安全原則)
- **決策**:付款 API 任何非成功回應,訂單都回到「未付款」
- **原因**:寧可少出一張單,也不能多出一張沒付錢的單
- **代價**:可能有少數正常交易被誤判為失敗
- **緩解**:提供顧客手動重試付款的功能

建議二:用 Threat Model 驅動設計原則的選擇

上一篇學的 STRIDE 威脅建模,和這一篇的設計原則是互補的:

STRIDE 威脅 最相關的設計原則
Spoofing(假冒) 完整調解、失敗安全
Tampering(竄改) 縱深防禦、完整調解
Repudiation(否認) 完整調解(稽核日誌)
Information Disclosure(洩露) 最小權限、最小共享
Denial of Service(阻斷服務) 縱深防禦、失敗安全
Elevation of Privilege(提權) 最小權限、預設安全

建議三:每個 Sprint 挑一個原則深化

不需要一次把七個原則全部做到滿分。建議每個 Sprint 挑一個原則作為重點:

Sprint 1:盤點所有帳號權限,落實最小權限
Sprint 2:檢查所有 catch 區塊,確保失敗安全
Sprint 3:審查所有預設設定,確保預設安全
Sprint 4:檢查 API 權限檢查是否有遺漏(完整調解)
...

逐步改善,比一次到位更務實。


七、常見問題 FAQ

Q1:七大原則之間會不會互相衝突?

會的,而且很常見。例如「完整調解」要求每次都做權限檢查,但太頻繁的檢查會影響效能,這跟使用者體驗的需求衝突。又例如「最小共享」建議資源隔離,但隔離越徹底,基礎設施成本越高。

安全設計的藝術就在於取捨(trade-off)。關鍵是:把取捨記錄下來,讓團隊理解「我們為什麼這樣選」。用上面提到的「安全設計決策紀錄」來追蹤每個決策的原因和代價。

Q2:我們是小團隊,哪些原則應該優先?

如果你的團隊資源有限,建議優先落實這三個:

  1. 預設安全:成本最低,效果最大。確保框架和工具的預設設定是安全的,一勞永逸。
  2. 最小權限:花一個下午盤點帳號權限,把過大的權限收回來。
  3. 失敗安全:檢查所有 <code>catch</code> 和 <code>else</code> 分支,確保異常時不會開後門。

這三個做好,已經能擋住大部分常見攻擊了。

Q3:這些原則跟 OWASP Top 10 有什麼關係?

OWASP Top 10 列的是「常見的漏洞類型」,安全設計原則是「預防漏洞的設計哲學」。它們的關係是:如果你在設計階段就遵循這些原則,很多 OWASP Top 10 的漏洞根本不會出現。

例如:遵循「最小權限」可以預防 A01:2021 — Broken Access Control(權限控制失效);遵循「預設安全」可以預防 A05:2021 — Security Misconfiguration(安全設定錯誤);遵循「失敗安全」可以預防 A07:2021 — Identification and Authentication Failures(認證失效)的部分情境。

Q4:我要怎麼說服 PM 或老闆投入時間在安全設計上?

用錢說話。IBM 的研究顯示,在設計階段修復安全問題的成本,只有上線後的 1/30 到 1/100。你可以用這個方式呈現:

「我們現在花 2 天做安全設計審查,等於省下上線後 2 個月的修補工作。你選哪個?」

另外,如果你的客戶是金融業或上市櫃公司,「資通安全管控指引」已經要求系統開發需求規格須納入資安要求——這不是你想不想做,而是客戶要求你必須做。


八、結語:好的設計,讓安全變成自然而然的事

回到蓋房子的比喻。你見過哪棟安全的建築,是蓋好之後才在外面綁一堆鐵絲網和沙袋的?真正安全的建築,安全就在結構裡——承重牆的位置、逃生梯的動線、防火區劃的規劃,這些都是在藍圖階段就決定的。

軟體系統也是一樣。七大安全設計原則不是「額外要做的事」,而是「設計時本來就該想的事」。當你養成用這些原則思考的習慣,你會發現很多安全問題在設計階段就被自然地避免了——根本不需要等到測試或上線才來亡羊補牢。

就像前幾篇一直在說的:安全不是恐懼,而是創造的基礎。好的安全設計不會限制你的創造力,反而會給你一個穩固的平台,讓你可以放心地在上面蓋出更大、更好、更有價值的東西。

下一篇,我們會繼續深入安全設計階段的另一個重要主題——認證與授權架構設計:從密碼到 Zero Trust。在你掌握了設計原則之後,我們來看看如何設計一個「確認你是誰」和「決定你能做什麼」的系統。


延伸閱讀

[安全設計] 001 威脅建模入門:用 STRIDE 找出系統弱點|DFD 資料流程圖與信任邊界實戰教學

「好的建築師不只想像住戶怎麼生活,也會想像小偷怎麼闖入。」— SSDLC by 飛飛

在 SSDLC 的旅程中,我們已經走過了「階段一:安全需求定義」,學會了怎麼用 Abuse Case 和安全驗收標準來定義「系統該防什麼」。現在,我們要進入階段二:安全設計——在畫建築藍圖的時候,就把消防通道、監視器位置、保全系統標清楚。

而安全設計的第一步,就是威脅建模(Threat Modeling)

威脅建模是什麼?簡單說,就是在系統還沒蓋好之前,先用攻擊者的眼光把整個設計看一遍,找出哪裡可能被攻擊、哪裡最脆弱、哪裡需要加強防護。

今天要介紹的 STRIDE,是微軟在 1999 年提出的威脅分類模型,也是目前業界最廣泛使用的威脅建模方法之一。它用六個英文字母,幫你系統化地思考所有可能的威脅類型。


為什麼需要威脅建模?不做會怎樣?

先說一個真實場景。

假設你正在開發一個台灣的線上訂餐平台。功能需求很清楚:使用者可以瀏覽餐廳、下訂單、線上付款、追蹤外送進度。你的團隊很認真地寫了使用者故事、做了 UI 設計、選好了技術架構。

然後上線了。

三個月後,有人發現:

  • 改一下 URL 裡的訂單編號,就能看到別人的訂單明細(包含地址和電話)
  • 外送員的 API 沒有做權限檢查,任何人都能呼叫「標記已送達」
  • 付款完成的通知用 HTTP 而不是 HTTPS,中間人可以攔截並偽造付款結果

這些問題,不是「程式寫錯」,而是設計階段就沒想到

飛飛觀點:
威脅建模不是在找 bug,而是在找「設計盲點」。Bug 是寫錯了,盲點是根本沒想到。修 bug 改幾行程式碼就好,修盲點可能要改整個架構。這就是為什麼威脅建模要在設計階段做——越早發現,修復成本越低。

如果用蓋房子的比喻:功能設計是決定「房間怎麼隔」,威脅建模是檢查「小偷從哪裡進來」。你不會等房子蓋好才想「咦,一樓窗戶是不是該裝鐵窗」,對吧?


STRIDE 是什麼?六個字母,六種威脅

STRIDE 是六種威脅類型的首字母縮寫,每一個字母代表攻擊者可能做的一類壞事:

字母 威脅類型 英文全名 一句話解釋 生活比喻
S 身分冒用 Spoofing 假裝是別人 有人拿假身分證冒充你去銀行領錢
T 資料竄改 Tampering 偷偷修改資料 有人改了你的考卷答案
R 否認行為 Repudiation 做了壞事卻說「不是我」 鄰居偷倒垃圾卻說「我沒有」,而且沒監視器能證明
I 資訊洩露 Information Disclosure 看到不該看的資料 有人翻了你的日記
D 阻斷服務 Denial of Service 讓系統無法正常運作 有人故意堵住餐廳門口,讓客人進不來
E 權限提升 Elevation of Privilege 用低權限做高權限的事 實習生拿到總經理的辦公室鑰匙

這六種威脅基本上涵蓋了大部分的攻擊模式。讓我們用前面的訂餐平台,逐一看看每種威脅長什麼樣子。


S — Spoofing 身分冒用:「你確定他是他嗎?」

攻擊情境:攻擊者偽造或竊取使用者的身分,以該使用者的名義進行操作。

訂餐平台範例

  • 攻擊者取得某使用者的 JWT Token,冒充該使用者下訂單並用其儲存的信用卡付款
  • 攻擊者架設一個假的「訂餐平台登入頁」(釣魚網站),騙使用者輸入帳號密碼
  • 外送員用別人的帳號登入,領取不屬於自己的配送任務

對應的安全屬性:認證(Authentication)

防禦方向:多因素驗證(MFA)、Token 過期與刷新機制、防止 Session Fixation


T — Tampering 資料竄改:「資料還是原本的嗎?」

攻擊情境:攻擊者在傳輸途中或儲存端修改資料。

訂餐平台範例

  • 攻擊者攔截訂單請求,將金額從 NT$350 改成 NT$1
  • 攻擊者修改外送地址,把餐點送到自己家
  • 攻擊者竄改資料庫中的評價紀錄,將一星評價改成五星

對應的安全屬性:完整性(Integrity)

防禦方向:HTTPS 傳輸加密、數位簽章、資料庫寫入稽核、請求參數伺服器端驗證


R — Repudiation 否認行為:「你能證明是他做的嗎?」

攻擊情境:使用者或攻擊者否認自己曾經執行過某項操作,而系統無法提供證據。

訂餐平台範例

  • 消費者下了訂單又退貨,聲稱「我從來沒下過這筆訂單」
  • 餐廳老闆收到客訴,卻說「我們沒有收到那張訂單」
  • 外送員宣稱已送達,但消費者說沒收到,而系統沒有送達時的 GPS 紀錄或照片

對應的安全屬性:不可否認性(Non-repudiation)

防禦方向:完整的操作日誌(Audit Log)、時間戳記、數位簽章、關鍵操作需要二次確認


I — Information Disclosure 資訊洩露:「這些資料你該看嗎?」

攻擊情境:未經授權的人取得敏感資訊。

訂餐平台範例

  • 改 URL 中的訂單 ID 就能看到別人的訂單,包含姓名、電話、地址
  • API 錯誤回應中洩漏資料庫結構(如:<code>ERROR: column "user_password" does not exist</code>)
  • 外送員的 API 回傳了消費者的完整信用卡號碼(應該只顯示後四碼)

對應的安全屬性:機密性(Confidentiality)

防禦方向:存取控制(確認資料所有權)、敏感資料遮罩、錯誤訊息統一化、最小化 API 回應欄位


D — Denial of Service 阻斷服務:「系統還活著嗎?」

攻擊情境:攻擊者讓系統無法正常服務合法使用者。

訂餐平台範例

  • 攻擊者用自動化腳本在用餐尖峰時段發送數萬筆假訂單,癱瘓訂單處理系統
  • 攻擊者對搜尋功能發送超複雜的查詢條件,拖慢資料庫回應時間
  • 攻擊者反覆呼叫「忘記密碼」功能,導致簡訊服務費用暴增(經濟型 DoS)

對應的安全屬性:可用性(Availability)

防禦方向:Rate Limiting、Request 大小限制、佇列機制、CDN 與 DDoS 防護、費用告警


E — Elevation of Privilege 權限提升:「你有權限做這件事嗎?」

攻擊情境:攻擊者從低權限角色獲取高權限操作能力。

訂餐平台範例

  • 一般使用者透過修改 API 請求參數,將自己的角色從 <code>customer</code> 改為 <code>admin</code>
  • 外送員帳號透過 API 直接存取後台管理介面,修改餐廳資訊
  • 攻擊者利用 Node.js 的 Prototype Pollution 漏洞,注入 <code>isAdmin: true</code> 屬性

對應的安全屬性:授權(Authorization)

防禦方向:伺服器端權限檢查(不信任前端傳來的角色)、RBAC 權限模型、輸入物件白名單驗證


飛飛觀點:
STRIDE 不是要你記住六個英文單字,而是給你一個「思考的框架」。下次設計功能時,不用再對著空白頁面想「會有什麼攻擊?」,而是可以按照 S-T-R-I-D-E 的順序逐一檢查。有框架比沒框架好,不完美的威脅建模也遠勝過完全不做。


威脅建模的核心工具:資料流程圖(DFD)

知道了六種威脅類型之後,下一個問題是:「我該對『什麼東西』做 STRIDE 分析?」

答案是:資料流程圖(Data Flow Diagram,DFD)

DFD 不是那種複雜到讓人想睡覺的 UML 圖。它只有四種元素,畫起來非常直覺:

元素 符號 說明 範例
外部實體(External Entity) 矩形 系統之外的人或系統 使用者、第三方支付閘道、外送員 App
處理程序(Process) 圓形 系統內處理資料的元件 訂單服務、認證服務、通知服務
資料儲存(Data Store) 兩條平行線 資料存放的地方 使用者資料庫、訂單資料庫、Redis 快取
資料流(Data Flow) 箭頭 資料的流動方向 登入請求、訂單資料、付款結果通知

實戰:畫出訂餐平台的 DFD

以訂餐平台的「下訂單」功能為例,DFD 大概長這樣:

                        ┌─────────────┐
                        │  信任邊界    │
┌──────────┐           │┌───────────┐│         ┌──────────────┐
│          │  訂單請求  ││           ││ 查詢菜單 │              │
│  使用者   │──────────→││  訂單服務  ││────────→│  餐廳資料庫   │
│ (瀏覽器)  │           ││           ││         │              │
│          │←──────────││           ││←────────│              │
└──────────┘  訂單確認  ││           ││ 菜單資料 └──────────────┘
                       ││           ││
                       ││           ││ 建立訂單  ┌──────────────┐
                       ││           ││────────→│  訂單資料庫   │
                       ││           ││         │              │
                       │└─────┬─────┘│         └──────────────┘
                       │      │      │
                       │      │付款請求
                       │      ↓      │
                       │┌───────────┐│
                       ││  付款服務  ││
                       │└─────┬─────┘│
                       └──────│──────┘
                              │ API 呼叫
                              ↓
                       ┌─────────────┐
                       │ 第三方支付閘道│
                       │ (綠界/藍新)  │
                       └─────────────┘

畫 DFD 的時候,有幾個原則要注意:

原則一:從使用者的角度出發
先想使用者會做什麼操作,資料從哪裡進來、經過哪些處理、最後存到哪裡。

原則二:一次只畫一個功能流程
不要試圖把整個系統畫在一張圖上。登入是一張、下訂單是一張、退款是一張。這樣才能聚焦分析。

原則三:標示清楚每條資料流的內容
不要只畫箭頭,要寫上「這條線上跑的是什麼資料」。例如:「JWT Token」、「訂單明細(含地址、電話)」、「信用卡 Token」。這會幫助你判斷哪條資料流最敏感。


信任邊界:威脅建模最關鍵的一條線

如果 DFD 是威脅建模的地圖,那信任邊界(Trust Boundary)就是地圖上最重要的那條線。

信任邊界是什麼?就是不同信任等級之間的分界線

用一個更直覺的比喻:想像你家的大門。門外是公共空間,什麼人都可能經過;門內是你的私人空間,只有你信任的人才能進來。那道門,就是信任邊界。

在軟體系統中,常見的信任邊界包括:

信任邊界 低信任側 高信任側 跨越邊界的資料
瀏覽器 ↔ 後端 API 使用者的瀏覽器(不可信) 你的伺服器 HTTP 請求、Cookie、JWT
後端 ↔ 資料庫 應用程式層 資料層 SQL 查詢、資料回傳
內部服務 ↔ 外部 API 你的系統 第三方服務(部分信任) API 呼叫、Webhook 回調
使用者角色之間 一般使用者 管理員 權限檢查、操作授權
容器 ↔ 主機 容器內的應用 主機作業系統 系統呼叫、檔案存取

為什麼信任邊界這麼重要?

因為大部分的攻擊都發生在信任邊界上

攻擊者的目標,就是找到那條線,然後想辦法跨越它:從不被信任的一側,滲透到被信任的一側。

回到訂餐平台的例子:

  • 瀏覽器 → API:攻擊者可以自由修改瀏覽器送出的請求(竄改訂單金額、偽造身分)
  • API → 資料庫:如果 API 沒有做好輸入驗證,攻擊者的惡意輸入會直接到達資料庫(SQL Injection)
  • API → 第三方支付:如果回調通知沒有驗證簽章,攻擊者可以偽造「付款成功」的通知

飛飛觀點:
在 DFD 上標示信任邊界後,把注意力集中在每一條「穿越信任邊界的資料流」上——這些就是你最需要做 STRIDE 分析的地方。不需要對圖上的每一條線都做完整分析,先守住邊界,就能防住大部分攻擊。


實戰演練:對訂餐平台做完整的 STRIDE 分析

理論講完了,我們來實際操作一次。以下用台灣訂餐平台的「使用者下訂單」功能,走一遍完整的 STRIDE 分析流程。

步驟一:畫出 DFD(上面已經畫好了)

步驟二:標示信任邊界

在我們的 DFD 中,主要的信任邊界有三條:

  1. 使用者瀏覽器 ↔ 後端 API(最外層邊界)
  2. 訂單服務 ↔ 付款服務(內部服務間)
  3. 付款服務 ↔ 第三方支付閘道(系統 ↔ 外部系統)

步驟三:對每條跨邊界的資料流做 STRIDE 分析

以下是分析結果,整理成一張威脅清單表格:

編號 資料流 STRIDE 威脅描述 風險等級 緩解措施
T-01 使用者 → 訂單服務 S 身分冒用 攻擊者竊取 JWT 冒充使用者下單 Token 短效期 + Refresh Token + 裝置綁定
T-02 使用者 → 訂單服務 T 資料竄改 攻擊者竄改請求中的商品價格或數量 伺服器端重新查詢價格,不信任前端傳值
T-03 使用者 → 訂單服務 I 資訊洩露 透過遍歷訂單 ID 取得他人訂單資訊 使用 UUID 取代流水號 + 所有權驗證
T-04 使用者 → 訂單服務 D 阻斷服務 大量送出假訂單癱瘓系統 Rate Limiting + CAPTCHA + 訂單佇列
T-05 使用者 → 訂單服務 E 權限提升 修改請求將自己角色改為管理員 伺服器端從 Token 解析角色,不接受前端傳值
T-06 訂單服務 → 訂單 DB T 資料竄改 SQL Injection 修改訂單資料 參數化查詢(ORM)
T-07 訂單服務 → 訂單 DB I 資訊洩露 錯誤訊息洩漏資料庫結構 統一錯誤回應格式
T-08 付款服務 → 金流閘道 S 身分冒用 攻擊者偽造回調通知假裝付款成功 驗證回調簽章 + 白名單 IP
T-09 訂單服務 R 否認行為 使用者聲稱未下過訂單 完整 Audit Log + 操作前確認頁面
T-10 付款服務 → 金流閘道 T 資料竄改 中間人竄改付款金額 HTTPS + 金額簽章驗證

步驟四:排定優先順序

不需要一次解決所有威脅。根據「風險等級」和「修復難度」來排序:

第一優先(高風險 + 容易修):T-02(價格驗證)、T-06(SQL Injection)、T-07(錯誤訊息)
第二優先(高風險 + 需要設計):T-01(Token 安全)、T-03(訂單隔離)、T-08(回調驗證)
第三優先(中風險 + 持續改善):T-04(DoS 防護)、T-09(Audit Log)


把威脅轉化為安全驗收標準

威脅建模的產出不應該只是一份文件——它應該變成可測試的安全驗收標準。這就是跟上一篇「安全需求規格書」的銜接點。

以 T-02(價格竄改)和 T-03(訂單資訊洩露)為例:

# T-02 對應的安全驗收標準
Feature: 訂單金額完整性保護

  Scenario: 前端傳送的商品價格不被信任
    Given 商品 A 在資料庫中的價格為 NT$350
    When 使用者送出訂單且請求中的商品價格被竄改為 NT$1
    Then 系統應以資料庫中的價格 NT$350 計算訂單金額
    And 不應採用前端傳送的價格

  Scenario: 訂單金額與商品價格一致
    Given 使用者將 2 份商品 A(NT$350)和 1 份商品 B(NT$200)加入購物車
    When 送出訂單
    Then 訂單金額應為 NT$900
    And 各品項金額應與資料庫即時價格一致
# T-03 對應的安全驗收標準
Feature: 訂單資料存取隔離

  Scenario: 使用者只能存取自己的訂單
    Given 使用者 A 有一筆訂單 ID 為 "order-uuid-123"
    And 使用者 B 已登入系統
    When 使用者 B 嘗試存取 GET /api/orders/order-uuid-123
    Then 系統應回傳 403 Forbidden
    And 回應中不應包含任何訂單資料

  Scenario: 訂單 ID 不可被遍歷
    Given 系統中存在多筆訂單
    When 攻擊者嘗試遍歷 GET /api/orders/1, /api/orders/2, /api/orders/3
    Then 系統應回傳 404 Not Found(不洩漏訂單是否存在)

對應的自動化測試範例:

// T-02: 價格竄改防護測試
describe('T-02: 訂單金額完整性', () => {
  it('應忽略前端傳送的價格,使用資料庫價格計算', async () => {
    const token = await loginAs('customer@example.com');

    // When: 送出訂單,但商品價格被竄改
    const response = await request(app)
      .post('/api/orders')
      .set('Authorization', <code class="kb-btn">Bearer ${token}</code>)
      .send({
        items: [
          { product_id: 'prod-001', quantity: 2, price: 1 }  // 竄改價格為 NT$1
        ]
      });

    // Then: 訂單金額應以資料庫價格計算
    expect(response.status).toBe(201);
    expect(response.body.total_amount).toBe(700); // 資料庫價格 NT$350 × 2
    expect(response.body.total_amount).not.toBe(2); // 不是竄改後的 NT$1 × 2
  });
});

// T-03: 訂單存取隔離測試
describe('T-03: 訂單資料隔離', () => {
  it('不應存取其他使用者的訂單', async () => {
    const tokenA = await loginAs('customer_a@example.com');
    const tokenB = await loginAs('customer_b@example.com');

    // Given: 使用者 A 建立一筆訂單
    const orderResponse = await request(app)
      .post('/api/orders')
      .set('Authorization', <code class="kb-btn">Bearer ${tokenA}</code>)
      .send({ items: [{ product_id: 'prod-001', quantity: 1 }] });

    const orderId = orderResponse.body.id;

    // When: 使用者 B 嘗試存取使用者 A 的訂單
    const accessResponse = await request(app)
      .get(<code class="kb-btn">/api/orders/${orderId}</code>)
      .set('Authorization', <code class="kb-btn">Bearer ${tokenB}</code>);

    // Then: 應被拒絕
    expect(accessResponse.status).toBe(403);
    expect(accessResponse.body).not.toHaveProperty('items');
    expect(accessResponse.body).not.toHaveProperty('delivery_address');
  });
});

威脅建模 Checklist:你的團隊可以直接用

以下是一份精簡的威脅建模 Checklist,適合在設計審查會議中使用:

前置準備

□ 已確定要分析的功能範圍(一次一個功能)
□ 已繪製 DFD(包含外部實體、處理程序、資料儲存、資料流)
□ 已標示所有信任邊界
□ 已標註每條資料流上傳輸的資料類型

STRIDE 逐項檢查

## S — Spoofing 身分冒用
□ 每個跨信任邊界的請求是否都有身分驗證?
□ Token / Session 的產生、傳輸、儲存是否安全?
□ 是否有防止 Session Fixation 和 Replay Attack 的機制?

## T — Tampering 資料竄改
□ 傳輸中的資料是否使用 HTTPS / TLS?
□ 伺服器是否獨立驗證所有關鍵資料(價格、數量、權限)?
□ 資料庫操作是否使用參數化查詢?

## R — Repudiation 否認行為
□ 關鍵操作(下單、付款、退款、修改權限)是否有 Audit Log?
□ Log 是否包含 who、what、when、where、result?
□ Log 儲存是否防竄改(不可被使用者或一般管理員刪除)?

## I — Information Disclosure 資訊洩露
□ API 回應是否只包含必要欄位(最小化原則)?
□ 錯誤訊息是否統一格式,不洩漏實作細節?
□ 敏感資料(密碼、Token、個資)是否有適當遮罩或加密?

## D — Denial of Service 阻斷服務
□ 對外 API 是否有 Rate Limiting?
□ 上傳功能是否有檔案大小限制?
□ 資料庫查詢是否有分頁和效能考量?

## E — Elevation of Privilege 權限提升
□ 權限檢查是否在伺服器端執行(不信任前端)?
□ 使用者角色是否從 Token / Session 取得(不接受 request body)?
□ 是否有防止 Mass Assignment / Prototype Pollution 的措施?

收尾

□ 每個識別出的威脅是否有對應的緩解措施?
□ 威脅是否已按風險等級排定優先順序?
□ 高風險威脅是否已轉化為安全驗收標準(Gherkin 格式)?
□ 威脅清單是否已記錄在專案文件中(版本化管理)?

在團隊中落地威脅建模:務實的建議

建議一:從「輕量版」開始,不要追求完美

很多團隊聽到「威脅建模」就覺得很重、很花時間。但威脅建模不需要一次就做到完美。

輕量版做法:在每次 Sprint Planning 或 Design Review 時,花 30 分鐘做以下動作:

  1. 畫一張簡單的 DFD(白板就好,不需要專業工具)
  2. 標出信任邊界
  3. 對每條跨邊界的資料流,快速過一遍 STRIDE
  4. 把識別出的威脅記錄在 Issue Tracker(如 Jira、GitHub Issues)

30 分鐘的威脅建模,遠勝過 0 分鐘。

建議二:指定一位「威脅建模引導者」

這個角色不需要是資安專家。他的工作是:

  • 確保每次設計新功能時,都有人問:「我們做過 STRIDE 分析嗎?」
  • 引導團隊在白板前畫 DFD、標信任邊界
  • 把討論結果記錄下來

這跟上一篇提到的 Security Champion 是同一個角色——在團隊中推動安全思維的人。

建議三:把威脅建模的產出與程式碼綁在一起

威脅清單不要只放在 Confluence 上吃灰塵。建議在專案目錄中建立 <code>threats/</code> 資料夾:

/project
  /src
  /tests
  /specs
  /threats
    login-flow.md       ← 登入功能的威脅分析
    order-flow.md       ← 下單功能的威脅分析
    payment-flow.md     ← 付款功能的威脅分析

每份威脅分析文件包含:DFD 圖(可以用 ASCII art 或 Mermaid)、STRIDE 分析表格、對應的緩解措施、連結到相關的安全驗收標準。

這樣做的好處是:威脅分析會跟著程式碼一起被版本控制,團隊成員可以在 Code Review 時交叉參考。

建議四:善用工具,但不依賴工具

以下是一些免費的威脅建模工具:

工具 特點 適合
OWASP Threat Dragon 開源、支援 DFD 繪製、自動產生 STRIDE 威脅 想要圖形化介面的團隊
Microsoft Threat Modeling Tool 微軟出品、功能完整、有威脅知識庫 Windows 環境的團隊
draw.io / Excalidraw 通用繪圖工具,彈性大 喜歡自由繪製的團隊
Mermaid 純文字語法、可版本控制 開發者友善、CI 整合

飛飛觀點:
工具只是輔助。一塊白板加六支彩色筆,就能做出很棒的威脅建模。重點不是圖畫得多漂亮,而是團隊有沒有一起思考過「攻擊者會怎麼做」。


常見問題 FAQ

Q1:威脅建模跟上一篇的 Abuse Case 有什麼不同?

它們是互補的,但切入角度不同:

面向 Abuse Case 威脅建模(STRIDE)
切入角度 單一功能出發,思考攻擊者怎麼濫用 系統架構出發,系統化盤點所有威脅
適合時機 撰寫安全需求規格時 系統設計或架構審查時
產出格式 Abuse Case 表格 + 安全驗收標準 DFD + STRIDE 威脅清單 + 緩解措施
關注層面 業務邏輯層面的攻擊 技術架構層面的威脅

最佳實踐是兩者搭配使用:先用 STRIDE 找出系統層面的威脅,再對高風險的功能用 Abuse Case 深入分析。

Q2:我們的系統很小,也需要做威脅建模嗎?

系統越小,威脅建模越快。一個三頁功能的小系統,30 分鐘就能做完基本的 STRIDE 分析。而且小系統一旦出事,影響可能比你想的大——因為小團隊通常也沒有專職資安人員來善後。

建議至少對以下功能做威脅建模:處理金流的功能、處理個資的功能、對外暴露的 API、認證與授權相關的功能。

Q3:STRIDE 以外還有其他威脅建模方法嗎?

有的。STRIDE 是最入門也最通用的,但如果你想更進一步:

  • PASTA(Process for Attack Simulation and Threat Analysis):以風險為中心的七步驟框架,更適合企業級的完整評估
  • LINDDUN:專門針對隱私威脅的建模方法,適合處理大量個資的系統
  • Attack Tree:用樹狀結構列出達成某個攻擊目標的所有可能路徑

對初學者來說,先掌握 STRIDE 就足以涵蓋大部分常見威脅。

Q4:威脅建模的結果多久要更新一次?

建議在以下情境時更新:

  • 系統架構有重大變更(如新增微服務、更換資料庫)
  • 新增涉及敏感資料的功能
  • 發生資安事件後的根因分析
  • 每年至少全面 Review 一次

不需要每次小改動都重做,但重大變更一定要重新評估。


結語:威脅建模是一種思維方式,不是一份文件

很多人把威脅建模想成「交差用的文件」——做完往 Wiki 一放,就再也沒人看。

但威脅建模的真正價值,不在那張 DFD 或那份威脅清單,而在於團隊養成了「用攻擊者視角看系統」的思維習慣

當你畫 DFD 時,你會自然地思考:「這條資料流上跑的是什麼?」
當你標信任邊界時,你會自然地問:「這裡的驗證夠嗎?」
當你做 STRIDE 分析時,你會自然地想:「如果我是攻擊者,我會怎麼做?」

這些思考習慣一旦內化,你設計出來的系統自然就會更安全。

就像前面說的:好的建築師不只想像住戶怎麼生活,也會想像小偷怎麼闖入。而好的開發者不只想像使用者怎麼操作,也會想像攻擊者怎麼破壞。

下一篇,我們會繼續探索安全設計階段的其他主題——安全設計原則:最小權限、縱深防禦、預設安全。在你知道威脅從哪來之後,我們來看看怎麼用設計原則把它們擋在門外。


延伸閱讀