iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0
Kubernetes

從零到一:使用 K8S + GitOps 打造異構技術棧的資料分析平台系列 第 6

[Day 6] 安全防護:API Gateway 路由設計與 JWT 統一認證機制 —— 設計微服務的門戶,實現無狀態的安全認證。

  • 分享至 

  • xImage
  •  

Day 6: 安全防護:API Gateway 路由設計與 JWT 統一認證機制

設計微服務的門戶,實現無狀態的安全認證。

1. 為什麼需要 API Gateway?

在單體架構中,前端只需要面對一支 API。但現在 Wafer BI 已經拆成 Java 管用戶、Python 管數據、Node.js 管 AI 等多個微服務,如果讓前端直接記住每個服務的 IP 跟 Port,不只架構混亂,跨域 (CORS) 跟安全驗證也沒辦法統一處理。

所以我們需要一個 API Gateway 當作統一的對外大門:前端永遠只跟 /api 講話,後面路由去哪、驗證怎麼做,都是 Gateway 的事。

2. Node.js Express 打造輕量級 Gateway

我們在 services/api-gateway/src/index.jsexpress + http-proxy-middleware 實作反向代理與路由轉發:

const { createProxyMiddleware } = require('http-proxy-middleware');

// 1. 登入/註冊走 Java User Service(不需要 token,不然要怎麼登入 哈哈)
app.use(
  createProxyMiddleware('/api/auth', {
    target: USER_SERVICE_URL,
    changeOrigin: true,
    pathRewrite: { '^/api/auth': '/auth' },
  })
);

// 2. 用戶管理需要先過 JWT 驗證,再轉發給 Java 服務
app.use('/api/users', authenticateToken);
app.use(
  createProxyMiddleware('/api/users', {
    target: USER_SERVICE_URL,
    changeOrigin: true,
    pathRewrite: { '^/api/users': '/users' },
  })
);

// 3. 其餘 /api 一律轉給 Python Wafer BI Service
app.use(
  createProxyMiddleware('/api', {
    target: WAFER_BI_URL,
    changeOrigin: true,
    pathRewrite: { '^/api': '' },
  })
);

注意路由的順序很重要:Express 是由上往下匹配,/api/auth/api/users 要放在萬用的 /api 前面,不然全部都會被送去 Python 那邊。

3. JWT (JSON Web Token) 的無狀態認證

這一節有個容易看混的地方,先把分工講清楚:簽發在 Java、驗簽在 Node.js。兩個語言、兩套函式庫,靠同一個環境變數 JWT_SECRET 串起來。

簽發端是 Java 的 user-service(UserService.java),用 jjwt 這個函式庫,只有登入成功(密碼比對通過)之後才會走到這裡:

private String generateToken(User user) {
    SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
    return Jwts.builder()
            .claim("user_id", user.getId())
            .claim("email", user.getEmail())
            .claim("group", user.getUserGroupName())
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + jwtExpirationMs))
            .signWith(key)          // HMAC-SHA256,密鑰就是那個共享的 JWT_SECRET
            .compact();
}

密鑰從 application.yml${JWT_SECRET} 注入,效期 24 小時。

驗簽端才是 Node.js 的 Gateway。後續每個 API 請求都在 Header 帶上 Authorization: Bearer <token>,Gateway 拿同一組 Secret 驗簽——不用查資料庫、也不用回頭問 Java 服務,這就是無狀態認證的漂亮之處:

const jwt = require('jsonwebtoken');

const authenticateToken = (req, res, next) => {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];

  if (!token) {
    return res.status(401).json({ error: 'Access token required' });
  }

  // 驗證 Token 合法性,不依賴後端資料庫 (無狀態)
  // 演算法鎖定 HMAC 家族:user-service 是用 Keys.hmacShaKeyFor() 簽的,
  // 出現其他演算法(例如 alg=none)就不是客戶端,是攻擊
  jwt.verify(token, JWT_SECRET, { algorithms: ['HS256', 'HS384', 'HS512'] }, (err, user) => {
    if (err) {
      return res.status(403).json({ error: 'Invalid or expired token' });
    }
    req.user = user;
    next();
  });
};

跨語言到底通不通? 這是異構架構最該實測、也最少人實測的一件事。實際用 admin 帳號打一次真的登入:

$ curl -X POST http://localhost:8080/api/auth/login \
    -H 'Content-Type: application/json' \
    -d '{"username":"admin","password":"..."}'

Java 回來的 token 拆開長這樣:

header : {"alg":"HS256"}
payload: {"user_id":1,"group":"admin","iat":1786198771,"exp":1786285171}

Keys.hmacShaKeyFor() 在 256 bit 密鑰下選的是 HS256,claims 就是 generateToken() 裡塞的那幾個。把這張 token 原封不動帶去 GET /api/users,Node 的 jsonwebtoken 驗完直接放行,回傳真實的用戶清單;把簽章最後一個字元改掉再打一次,立刻變 403。

JWT 之所以能當異構系統的通用通行證,靠的就是這件事:簽發方和驗證方不需要是同一種語言,只需要是同一個密鑰。 Java 用 jjwt 簽、Node 用 jsonwebtoken 驗,中間沒有任何一方需要知道對方是誰。

4. 踩坑實錄:我的 JWT 驗證其實沒在動?

寫這篇的時候我做了個完整測試,結果……哭啊,/api/users 不帶 token 竟然直接回 200,用戶清單整包吐出來。

排查後發現,我原本把 authenticateToken 寫在 proxy 的 options 裡:

// ✗ 錯誤寫法:authenticateToken 只是 options 裡的一個屬性,proxy 根本不理它
createProxyMiddleware('/api/users', {
  target: USER_SERVICE_URL,
  authenticateToken,   // ← 這行是裝飾品,完全不會被執行
  ...
})

http-proxy-middleware 不認識這個 option,所以它就……被靜靜地忽略了。正確做法是把它當成 Express middleware 掛在 proxy 前面(就是上面第 2 段的寫法)。修完之後的完整驗證流程:

https://ithelp.ithome.com.tw/upload/images/20260808/20182549fjIu7xnXkB.png

▲ JWT 認證實測:無 token 401、假 token 403、登入取得 token 後 200

  • 不帶 token → 401 Unauthorized
  • 帶假 token → 403 Forbidden
  • 登入拿到 token 再請求 → 200,正常回傳用戶清單

這個坑給我的教訓是:安全機制一定要用「攻擊者視角」實測,不能寫完看它沒報錯就當作有在保護。程式碼不會執行你以為它會執行的東西,它只執行你寫的東西。

5. 第二個坑:那個「以防萬一」的預設密鑰

同一節的程式碼,我後來又抓到一個更隱蔽的問題。原本的驗簽是這樣寫的:

// ✗ 看起來很貼心,實際上是後門
jwt.verify(token, process.env.JWT_SECRET || 'wafer_bi_platform_default_secret_key_32_bytes_long', ...)

那個 || 後面的預設值,是為了「環境變數沒設也能跑起來」而留的。問題是:這串字就寫在 public repo 的原始碼裡。只要哪天部署時漏了 JWT_SECRET——K8S 少掛一個 Secret、compose 少一行環境變數都算——Gateway 就會用一組全世界都看得到的密鑰驗簽,任何人都能自己簽一張 token 走進 /api/users,而且服務會正常啟動、健康檢查全綠、日誌一片安靜

有趣的是 Java 那邊沒有這個問題:application.yml 寫的是 ${JWT_SECRET},沒有預設值,少了就直接啟動失敗。同一個專案、同一個密鑰、兩種語言,一邊 fail fast、一邊悄悄降級——不一致本身就是漏洞

修法是讓 Node 跟 Java 對齊,寧可起不來也不要假裝有在保護:

const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
  console.error('[FATAL] JWT_SECRET is not set. ... refusing to start.');
  process.exit(1);
}
// 32 bytes 的門檻對齊 Java:Keys.hmacShaKeyFor() 低於 256 bit 會直接拒絕
if (Buffer.byteLength(JWT_SECRET, 'utf8') < 32) {
  console.error('[FATAL] JWT_SECRET must be at least 32 bytes ... refusing to start.');
  process.exit(1);
}

改完之後在 Docker Compose 的完整環境(Postgres + Java user-service + Node gateway)把每種情境都打過一輪:

情境 結果
容器不給 JWT_SECRET 就啟動 [FATAL] ... refusing to start,exit 1
JWT_SECRET 只有 21 bytes 同樣 exit 1
GET /api/users 不帶 token 401
帶亂打的 token 403
竄改真 token 的簽章最後一碼 403
用舊的那組硬編碼預設值簽的 token 403
alg=none 的偽造 token 403
真的登入拿到的 token 200,正常回傳用戶清單

倒數第三行是這次修改的重點:同一張偽造 token,在修改前只要環境變數漏設就會是 200

這裡有個部署上的副作用要一起講:fail fast 意味著少設 JWT_SECRET 的後果從「靜默不安全」變成「Pod CrashLoopBackOff」。看起來變嚴重了,但這正是我們要的——會 CrashLoop 的問題有人會修,靜默不安全的問題沒有人會發現。Day 29 的排查技巧就是為了這種會叫的故障準備的。

6. 額外的防禦機制

除了身份驗證,Gateway 也是抵禦攻擊的第一道防線。我們加入了:

  • Rate Limiting (express-rate-limit):限制每分鐘請求次數(截圖裡的 RateLimit-Remaining header 就是它),防止暴力嘗試。
  • Helmet:自動補上各種 Security Headers。
  • Trace ID:為每個請求注入 x-trace-id,方便日後在分散式架構中追蹤日誌(Day 24 的伏筆)。

7. 小結

今天裝好的不只是門禁系統,還有兩個關於「安全」的教訓,而且它們長得很像:驗證中介層寫在錯的位置(根本沒執行)、預設密鑰寫在對的位置(執行了,但保護的是空氣)。兩次都是程式碼看起來完全正常、服務跑得好好的,只有真的用攻擊者視角打一輪才會現形。

明天來解決另一個痛點:怎麼讓整套異構系統在開發機上一鍵起床——Docker Compose 登場。


上一篇
[Day 5] 前端視覺化:React + Echarts 的極致呈現 —— 如何流暢地在前端渲染數萬個數據點的晶圓地圖。
下一篇
[Day 7] 本地開發:使用 Docker Compose 模擬微服務環境 —— 在開發機上一鍵啟動整套異構系統的技巧。
系列文
從零到一:使用 K8S + GitOps 打造異構技術棧的資料分析平台19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言