設計微服務的門戶,實現無狀態的安全認證。
在單體架構中,前端只需要面對一支 API。但現在 Wafer BI 已經拆成 Java 管用戶、Python 管數據、Node.js 管 AI 等多個微服務,如果讓前端直接記住每個服務的 IP 跟 Port,不只架構混亂,跨域 (CORS) 跟安全驗證也沒辦法統一處理。
所以我們需要一個 API Gateway 當作統一的對外大門:前端永遠只跟 /api 講話,後面路由去哪、驗證怎麼做,都是 Gateway 的事。
我們在 services/api-gateway/src/index.js 用 express + 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 那邊。
這一節有個容易看混的地方,先把分工講清楚:簽發在 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 驗,中間沒有任何一方需要知道對方是誰。
寫這篇的時候我做了個完整測試,結果……哭啊,/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 段的寫法)。修完之後的完整驗證流程:

▲ JWT 認證實測:無 token 401、假 token 403、登入取得 token 後 200
401 Unauthorized
403 Forbidden
200,正常回傳用戶清單這個坑給我的教訓是:安全機制一定要用「攻擊者視角」實測,不能寫完看它沒報錯就當作有在保護。程式碼不會執行你以為它會執行的東西,它只執行你寫的東西。
同一節的程式碼,我後來又抓到一個更隱蔽的問題。原本的驗簽是這樣寫的:
// ✗ 看起來很貼心,實際上是後門
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 的排查技巧就是為了這種會叫的故障準備的。
除了身份驗證,Gateway 也是抵禦攻擊的第一道防線。我們加入了:
express-rate-limit):限制每分鐘請求次數(截圖裡的 RateLimit-Remaining header 就是它),防止暴力嘗試。x-trace-id,方便日後在分散式架構中追蹤日誌(Day 24 的伏筆)。今天裝好的不只是門禁系統,還有兩個關於「安全」的教訓,而且它們長得很像:驗證中介層寫在錯的位置(根本沒執行)、預設密鑰寫在對的位置(執行了,但保護的是空氣)。兩次都是程式碼看起來完全正常、服務跑得好好的,只有真的用攻擊者視角打一輪才會現形。
明天來解決另一個痛點:怎麼讓整套異構系統在開發機上一鍵起床——Docker Compose 登場。