部署狀態: Prod 與 Test 環境皆已上線。
環境 Frontend Backend Compose Migrations Prod https://df-it-sso-management.it.zerozero.twhttps://df-it-sso-login.it.zerozero.twdocker-compose-prod.ymlbackend/migrations/prod/Test https://df-sso-management-test.apps.zerozero.twhttps://df-sso-login-test.apps.zerozero.twdocker-compose-test.ymlbackend/migrations/dev/Dev http://localhost:3000http://localhost:3001docker-compose-dev.ymlbackend/migrations/dev/
建立企業統一 SSO(Single Sign-On)單一登入系統:
- 各子專案透過 OAuth2 Client Credentials(
app_id+app_secret)接入 SSO 中央 - 登入 App-A → App-B 自動登入(中央 session 共享)
- 登出 App-A → SSO 刪除中央 session + back-channel 通知所有 App
- 每個 App 可註冊多個
redirect_uris(同一組 credentials 可橫跨多個 origin,例如 dev + prod) - 子專案整合只需 5 個檔案 + 4 個環境變數
┌─────────────────────────────────────────────────────────────────────┐
│ 使用者瀏覽器 │
└──────┬──────────────┬──────────────┬────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────┐ ┌──────────┐
│ SSO Frontend │ │ App-A │ │ App-B │ ...更多子專案
│ (Next.js) │ │(Next.js) │ │(Next.js) │
│ Port 3000 │ │Port 3100 │ │Port 3200 │
│ │ │ │ │ │
│ 單頁 Dashboard │ │本地 token │ │本地 token │
│ (Tab 切換) : │ │ cookie │ │ cookie │
│ ‧應用程式管理 │ │API routes │ │API routes │
│ ‧登入紀錄 │ │(callback, │ │(callback, │
│ ‧管理員管理 │ │ me, logout)│ │ me, logout)│
└──────┬───────┘ └────┬─────┘ └────┬─────┘
│ │ │
│ ┌─────────┴────────────┘
│ │ server-to-server
▼ ▼ (code exchange + client credentials / Bearer token)
┌──────────────────────────────────┐
│ SSO Backend (Express.js) │
│ 本機 3001 / Test 容器 35890 │
│ │
│ OAuth2 Authorization Server: │
│ ‧app_id + app_secret 管理 │
│ ‧redirect_uris 多環境註冊 │
│ ‧HMAC-SHA256 back-channel 簽章 │
│ ‧timingSafeEqual 防 timing attack│
│ ‧Auth Code Lua 原子操作 │
│ │
│ 安全機制: │
│ ‧Helmet (安全 headers) │
│ ‧Rate Limiting(分層) │
│ ‧CORS 動態白名單 (redirect_uris)│
│ ‧重導向白名單驗證 │
│ ‧Session fixation 防護 │
│ ‧adminAuth 中間件 (後台 API) │
│ ‧環境變數啟動驗證 │
│ ‧Graceful Shutdown │
└────┬────────┬─────────┬──────────┘
│ │ │
┌────▼───┐ ┌──▼─────┐ ┌─▼──────────┐
│Microsoft│ │Postgre │ │ Redis │
│Azure AD │ │ SQL │ │ (ioredis) │
│(OAuth) │ │ 16 │ │ 7 │
└─────────┘ └────────┘ └────────────┘
│
┌─────▼──────┐
│ ERP API │
│ (員工資料) │
└────────────┘
使用者訪問 App-A → /api/auth/me → 無 token → 401 "no_token"
│
│ Client App 依「登入頁兩種模式」擇一處理(見後段):
│ • 嚴格模式:顯示登入按鈕,使用者點擊後觸發下方流程
│ • 企業 Portal 模式:登入頁 useEffect 直接 window.location → /authorize
│
▼
[1] 瀏覽器導向 SSO Backend
GET /api/auth/sso/authorize?client_id={app_id}&redirect_uri={APP_URL}/api/auth/callback
│
▼
[2] SSO 驗證
- 用 client_id 查 sso_allowed_list.app_id
- 驗證 redirect_uri origin 在 redirect_uris[] 中
- 檢查是否已有中央 session(JWT cookie + Redis)
│
├─ 已有 session ──→ [5] 直接產生 auth code(免登入!)
│
▼ 無 session
[3] 重導向到 Microsoft 登入頁
- session 記住 ssoRedirect = redirect_uri
- session.regenerate() 防 session fixation
│
▼
[4] OAuth 回調
- 用 authorization code 換取 tokens
- state 用 timingSafeEqual 比對(防 CSRF)
- 查詢 ERP API 取得員工資料
- 寫入 sso_login_log
- 產生 JWT + 寫入 Redis Session (24h TTL)
- 設定 httpOnly cookie (domain: .apps.zerozero.tw)
│
▼
[5] 產生一次性 Auth Code
- 隨機 32 bytes hex,存入 Redis(TTL 60 秒)
- 內含:userId, email, name, erpData
- 重導向:redirect_uri?code=xxx
│
▼
[6] App-A /api/auth/callback 接收 code
- POST SSO /api/auth/sso/exchange { code, client_id, client_secret }
- SSO 用 timingSafeEqual 驗證 client_secret
- SSO 用 Lua script 原子性 GET+DEL auth code
- 回傳 { user, token }
- App-A 將 token 存入 httpOnly cookie(本地 domain)
- 重導向 /dashboard
│
▼
[7] App-A Dashboard
- /api/auth/me → 讀本地 token → Bearer 轉發 SSO /api/auth/me
- SSO 驗證 JWT + Redis Session → 回傳用戶資料
「跨 App 一鍵登入」的機制不靠 cookie 跨域共享(cookie 仍是 per-domain),而是靠SSO 中央 session cookie 持久 + 各 Client App 自動串接 OAuth 鏈:
[使用者已在 App-A 登入過 → SSO 中央 domain 上有 session cookie]
↓
打開 App-B → /api/auth/me 401 "no_token"
↓
[Client App 行為依模式分流]
│
├─ 嚴格模式:顯示登入按鈕 → 等使用者點擊 → 之後流程同 Portal
│
└─ 企業 Portal 模式:登入頁 useEffect 自動 window.location → /authorize
│
↓
/authorize?client_id={app_b_id}&redirect_uri=...
↓
SSO 中央看 cookie:有中央 session → 直接產生 auth code(不碰 Microsoft!)
↓
App-B callback → /sso/exchange → 寫本地 cookie → Dashboard
Microsoft 365 風格的「打開即進」UX 必須選企業 Portal 模式(嚴格模式至少要點一次按鈕)。兩種模式的取捨見「登入頁兩種模式」段。
App-A 點登出 → /api/auth/logout
→ 讀本地 token → POST SSO /api/auth/logout (Bearer)
body: { redirect: "<APP_URL>/?logged_out=1" }
→ SSO 刪除 Redis session(sso:session:{userId})
→ SSO back-channel POST 所有 App /api/auth/back-channel-logout
{ user_id, timestamp, signature } ← HMAC-SHA256(app_secret, user_id:timestamp)
→ SSO 驗證 redirect origin 在 sso_allowed_list(剝除 path/query/fragment)
→ 回傳 { message, redirect: "<APP_ORIGIN>/?logged_out=1" }
→ App-A 清除本地 cookie → 302 redirect
設計原則:登出只清「中央 + App 兩層」,不動 AD(Microsoft)那層。
| 層級 | 由誰維護 | 登出時 |
|---|---|---|
| AD session | Microsoft(長壽) | 不動 — 避免使用者每次登入都被迫重打密碼 / MFA / passkey |
| 中央 SSO session | DF-SSO Redis(key = sso:session:{userId}) |
刪除 |
| App session | 各 Client App cookie | back-channel 收到通知後刪除 |
「登出視覺有效」由 Client App 登入頁的模式選擇決定——詳見下節「登入頁兩種模式」。嚴格模式下使用者會看到登入頁;Portal 模式為了換取「跨 App 零互動」UX,視覺上會被 silent re-auth 抵消(中央 session 真實狀態仍正確)。
每個 Client App 必須在兩個模式中明確擇一,不可混用。決策依 App 性質:
| 模式 | 登入頁 401 行為 | 適用情境 | 取得 | 放棄 |
|---|---|---|---|---|
| 嚴格模式(預設) | 顯示登入按鈕,禁止自動 redirect | 對外 / 含敏感操作(金流、客戶資料、內部審批) | 登出視覺明確——使用者看到「未登入」狀態 | 每個 App 一輩子至少點一次按鈕 |
| 企業 Portal 模式 | 沒 cookie / 401 → 自動 window.location.href = <auth-login-url> 進 SSO 鏈 |
全公司內部 admin 工具(部署平台、監控、報表) | Microsoft 365 級「打開即進」UX | 登出視覺傳遞會被 silent re-auth 抵消 |
兩個模式的共同底線:
| 規則 | 為什麼 |
|---|---|
?logged_out=1 旗標下不自動 redirect |
主動登出至少要在當下 App 留下視覺痕跡(綠色「已成功登出」訊息 + 登入按鈕) |
| Dashboard 工作中 401 都走 silent re-auth | 兩個模式對「session 自然過期」的恢復行為一致 |
| 硬性契約 #1 / #2 / #4 | 兩個模式都要遵守 |
中央 session 被刪 → AD silent SSO 還活著 → 如果登入頁自動 redirect 到 /authorize,會 silent 秒回 dashboard,登出毫無視覺效果。嚴格模式用「強制使用者點按鈕」當煞車——主動性回到使用者手上。
Portal 模式的「跨 App 一鍵登入」與「登出視覺消失」是同一個機制的兩面:
- 中央 session 被刪後,AD session 還活著(DF-SSO 設計刻意不動 AD 那層)
- Portal 模式自動 redirect → silent SSO 立刻補新 session → 看似沒登出
- 但中央 session 真的死了——舊的所有 token 都失效,使用者拿到的是新 session、新 cookie
接受這個取捨等於接受「內部使用者體感平順 > 登出視覺立即傳遞」這個價值排序。對外 App 不可選此模式。
純 Mode A 可以無條件 auto-redirect。Mode B(App 同時有 SSO 使用者與本地帳號使用者)必須 provider-aware,否則本地帳號使用者會看不到帳密表單:
| 使用者狀態 | 登入頁應該做的事 |
|---|---|
| 從未登入過 / 不知 provider | 顯示兩個選項:SSO 按鈕 + 本地帳密表單,不自動 redirect |
| 上次登入是 SSO(前端有 hint) | auto-redirect 到 SSO(Portal 行為) |
| 上次登入是 local(前端有 hint) | 直接顯示帳密表單,不走 SSO |
實作建議:登入成功後寫一顆 非 httpOnly 的提示 cookie 或 localStorage 鍵 last_login_provider=sso|local(這個 hint 不是憑證、洩漏無害——只決定登入頁長什麼樣);登出時清掉,避免「我已經改用本地帳號了,但登入頁還一直 auto-redirect 到 SSO」卡住。
範本實作見 INTEGRATION.md。MockA / MockB 是嚴格模式參考;Coolify-API-Integration 是企業 Portal 模式參考。
backend 行為兩個情境完全一致(都是「沒中央 session → 走 OAuth 重建」);差別在 Client App 端的視覺:
情境 A:中央 session 自然過期(24h TTL 到)
/api/auth/me → 401 "session_expired" → Client 清本地 cookie → 回首頁
嚴格模式:登入頁顯示按鈕 → 使用者點 → /authorize → AD silent → 建立新 session → Dashboard
Portal 模式:登入頁 useEffect 自動 redirect → /authorize → AD silent → 建立新 session → Dashboard
(兩個模式體感差別:嚴格多一個「點按鈕」動作;Portal 完全無感)
情境 B:使用者按了登出
App-A 觸發登出 → 中央刪 sso:session + back-channel 通知所有 App
→ App-B 本地 cookie 被清
嚴格模式:使用者下次回 App-B → 看到登入頁 → 必須主動點按鈕才能再進去
✓ 登出視覺有效
Portal 模式:使用者下次回 App-B → useEffect 自動 redirect → AD silent → 進 Dashboard
✗ 登出視覺被抵消(但中央 session 真的死了,舊 token 全失效)
例外:當使用者主動從 App-B 自家點登出 → 落地 ?logged_out=1 →
此 query string 下登入頁不 auto-redirect,顯示「已成功登出」+ 按鈕
「登出真有效」的契約落點不同:
- 嚴格模式:靠 Client App 顯示登入頁(contract #3 嚴格模式)保證使用者看到未登入狀態
- Portal 模式:放棄跨 App 視覺傳遞,僅靠
?logged_out=1例外保留「當下 App 看到登出訊息」這一條最低限度視覺
Portal 模式有個副作用:使用者主動登出 App A 後,打開 App B 會被 silent re-auth 拉回 dashboard,「登出」視覺上像沒發生。雖然中央 session 真的死了(舊 token 全失效),但視覺上抵消了使用者主動登出的意圖。
解這個 UX 問題的可選機制——「Single Logout 加強模式」靠 server-side 短期黑名單 + 401 response header + httpOnly hint cookie 把「剛被踢」與「自然過期」這兩種 401 區分開來:
[App A 登出]
↓ 中央 push back-channel 給所有 App
[App B 後端] HMAC 驗過 → mark recently_logged_out[user_id] = now (TTL 5 min)
[使用者觸發 App B 任何 fetch → /me 401(still has stale sso_token)]
[App B 後端] 解 token payload 取 user_id(不驗簽,純讀 claims)
→ 查 recently_logged_out cache → 命中
→ 401 response 同時做:
a. 加 header `X-Recently-Logged-Out: 1`
b. 種 hint cookie `sso_recent_logout=1`(httpOnly, 5min TTL)
c. 刪 sso_token cookie(已是失效 token)
↓
[App B 前端] silent re-auth 看到 header → 跳 `/?logged_out=1`,不走 SSO authorize
↓
使用者看到「已成功登出」綠色橫條 + 登入按鈕
★ 漏洞補強:使用者刪 url、開新 tab、跑任意 page route ★
↓
[使用者刪 ?logged_out=1 重輸 / → /me no_token]
[App B 後端] sso_token 沒了,**改查 sso_recent_logout cookie**
→ 命中 → 仍然回 header `X-Recently-Logged-Out: 1`
↓
[App B 前端] silent re-auth 又跳 /?logged_out=1
↓
[使用者跑任何 route /servers/abc 也一樣]
→ 同樣被 hint cookie 攔下顯示登出視覺
為什麼要種 cookie 不是只有 cache: JWT 一被刪後,server 從「沒帶 token 的 request」認不出是同一個使用者,cache 查無此人 → silent re-auth 又把人拉回。Hint cookie 是獨立的一顆 cookie,刪 sso_token 不會動到它,5 分鐘自然過期。Cookie httpOnly 防 JS 偽造。
完整契約(雙向、跨語言、TTL 取值理由、多 instance 注意事項)見 INTEGRATION.md「Single Logout 加強模式」。Coolify-API-Integration 是參考實作:backend/app/services/logout_cache.py + backend/app/utils/jwt_decode.py + frontend/src/lib/silent-reauth.ts 的 recentlyLoggedOut 分支。
不實作不違反任何硬性契約——只是 Portal 模式的 UX 加強。嚴格模式天生不需要(登入頁顯示按鈕、silent re-auth 不自動觸發)。
「為什麼 Microsoft 365 可以打開 outlook 即進、DF-SSO 不可以(嚴格模式)」這個問題的答案在 登出時殺 AD 那層的權力:
| Microsoft 365 | DF-SSO | |
|---|---|---|
| 登入機制 | 各 App 自動 redirect 到 login.microsoftonline.com,看 cookie 有則 silent |
同樣的 OAuth flow,但 Client App 是否 auto-redirect 由模式決定 |
| 登出殺到哪層 | App + AD(Microsoft 自己控制 AD 那層) | App + 中央 session,不動 AD |
| 自動 redirect 後撞牆 | 撞 AD 沒 cookie → 跳 Microsoft 登入畫面 ✓ 登出有效 | 撞 AD 還活著 → silent 秒回 ✗ 登出無效 |
| 結果 | 自動 redirect + 登出有效(都拿到) | 兩者只能擇一(嚴格模式 vs Portal 模式) |
DF-SSO 為什麼不殺 AD 那層:AD 是公司 Microsoft Azure 帳號,殺掉等於強迫使用者下次 Outlook / Teams / SharePoint 全部重輸密碼 + MFA。這對內部生產力衝擊太大,所以 DF-SSO 的「登出」設計成只殺中央 + App 兩層,AD 由 Microsoft 自己管。
結論:DF-SSO 不可能完全做到 Microsoft 365 的 UX——它沒有殺 AD 的權力。規範用「兩種模式擇一」讓 App 自己選:要登出視覺有效 → 嚴格模式(接受按鈕成本);要打開即進 → Portal 模式(接受登出視覺消失,僅留 ?logged_out=1 最低限度視覺)。
| App | 模式 | 為什麼 |
|---|---|---|
| MockA / MockB | 嚴格模式 | 規範範本實作,測試 contract #3 嚴格行為 |
| Coolify-API-Integration | 企業 Portal 模式 | 內部 admin 部署平台,使用者群小、互信高、需要跨 App 流暢 UX |
| df-it-agents-platform | (依 App 性質決定) | — |
| 將來的 CRM / 外部 App | 嚴格模式 | 含客戶資料 / 敏感操作,登出視覺必須有效 |
| 機制 | 說明 |
|---|---|
| OAuth2 Client Credentials | 每個 App 有 app_id(公開)+ app_secret(保密),exchange 時驗證 |
| timingSafeEqual | client_secret、OAuth state 比對使用常數時間,防 timing attack |
| HMAC-SHA256 簽章 | back-channel logout 帶 signature = HMAC(app_secret, user_id:timestamp),Client 驗證防偽造 |
| Timestamp 驗證 | back-channel 簽章含 timestamp,30 秒內有效,防 replay attack |
| Auth Code 原子操作 | Redis Lua script GET+DEL,防同一 code 重複使用 |
| Session fixation 防護 | 登入成功後 req.session.regenerate() 重新產生 session ID |
| Redirect 白名單 | authorize 的 redirect_uri 和 logout 的 redirect 都驗證 redirect_uris[] |
| Protocol 限制 | logout redirect 只允許 http: / https: 協定,防 javascript: 注入 |
| Logout Origin 剝離 | 驗證通過後只保留 URL.origin,剝除 path/query/fragment 防注入 |
| Secret 遮蔽 | API 列表只回傳 app_secret_last4,完整 secret 需管理員呼叫 /credentials |
| adminAuth 中間件 | /api/allowed-list、/api/login-log、/api/admin-manager 須驗證 JWT + Redis Session + 管理員白名單 |
| Self-delete 防護 | DELETE /api/admin-manager/:uid 禁止管理員刪除自己 |
| Helmet | X-Content-Type-Options、X-Frame-Options、HSTS 等安全 headers |
| Rate Limiting | 分層限制(見下表) |
| Body Size Limit | express.json() / urlencoded() 限制 1MB |
| trust proxy | app.set('trust proxy', 1) 讓 rate limiter 從 X-Forwarded-For 正確取 IP |
| Graceful Shutdown | SIGTERM/SIGINT → 停止接受新連線 → 等待完成 → 關閉 DB/Redis |
Rate limit 設定不寫死在程式碼內,實際值來自 sso_setting 表的 rate_limit.* 四筆 seed,可於 Dashboard 的「設定」分頁即時調整;backend 由 services/rateLimitManager.js 的 wrapper middleware 指向可變的 limiter instance,PUT /api/sso-setting/:key 成功後會 reload() 重建 instance 讓新值立即生效(視窗計數會重置)。若啟動時 DB 不可用則 fallback 到下表預設值。
| 範圍 | 預設值 | 說明 |
|---|---|---|
| 全域 | 500 / 15min | 防 DoS |
| Auth(login/redirect/authorize) | 30 / 15min | 防暴力登入 |
| Session(/me、POST /logout) | 100 / 15min | Client App 高頻 server-to-server |
| Exchange | 20 / 1min | 防 auth code 猜測 |
| 項目 | 值 | 位置 |
|---|---|---|
| 中央 session TTL | 24 小時(Redis sso:session:*) |
backend/routes/auth.js SESSION_TTL |
| JWT 過期時間 | 24 小時(JWT_EXPIRES_IN 可覆寫) |
backend/config/index.js |
| 一次性 Auth Code TTL | 60 秒,Lua GET+DEL 原子消耗 |
backend/routes/sso.js AUTH_CODE_TTL |
| Express Session(OAuth state)TTL | 10 分鐘 | backend/server.js |
| Back-channel HMAC timestamp 容忍 | 30 秒(Client 端驗證) | INTEGRATION.md 範本 |
| Back-channel 對單一 App 的 fetch timeout | 5 秒(AbortSignal.timeout) |
backend/routes/auth.js / sso.js |
| CORS origin 快取 TTL | 60 秒 | backend/server.js CORS_CACHE_TTL |
| Graceful shutdown 強制關閉 | 10 秒後 process.exit(1) |
backend/server.js |
| 項目 | 限制 |
|---|---|
| Request body | 1 MB(express.json({ limit: '1mb' })) |
app_secret 長度 |
64 字元 hex(crypto.randomBytes(32)),exchange 先檢查長度再 timingSafeEqual |
| Auth code 長度 | 64 字元 hex,exchange 前嚴格比對長度 |
| OAuth state 長度 | 64 字元 hex |
Cookie sameSite |
lax;secure 僅在 NODE_ENV=production 開啟 |
| Redirect URL 協定 | 僅允許 http: / https:(防 javascript: 注入) |
| 項目 | 限制 | 位置 |
|---|---|---|
每個 App 的 redirect_uris 筆數 |
最多 10 筆 | backend/routes/allowedList.js |
/api/login-log pageSize |
1 – 100(超出自動 clamp) | backend/services/loginLog.js |
管理員 email 格式 |
必須符合 ^[^\s@]+@[^\s@]+\.[^\s@]+$ |
|
domain 格式 |
必須為合法 URL,協定限 http: / https: |
|
重複 domain |
DB UNIQUE 約束(軟刪除時不衝突;建立時若存在軟刪除同名紀錄會自動恢復) |
所有值由
sso_setting表(category =rate_limit)動態載入,管理員可於 Dashboard「設定」分頁即時調整;以下為 seed / fallback 預設值。
| 範圍 | 預設值 | sso_setting key |
|---|---|---|
| 全域 | 500 次 / 15 分鐘 / IP | rate_limit.global |
Auth (/login、/redirect、/authorize) |
30 次 / 15 分鐘 / IP | rate_limit.auth |
Session (/me、POST /logout) |
100 次 / 15 分鐘 / IP | rate_limit.session |
Exchange (POST /sso/exchange) |
20 次 / 1 分鐘 / IP | rate_limit.exchange |
使用
app.set('trust proxy', 1),rate limit 會從X-Forwarded-For取真實 IP。 修改設定後 wrapper middleware 會重建 limiter instance,當前視窗的計數會歸零。
| 項目 | 規則 |
|---|---|
/api/allowed-list、/api/login-log、/api/admin-manager |
必須通過 adminAuth:JWT 有效 → Redis session 存在 → email 在 sso_admin_manager 且 is_active = TRUE |
| 非 SSO 流程直接登入管理後台 | email / azure_oid 須在 sso_admin_manager,否則跳 ?error=not_admin |
SSO 流程(ssoRedirect 存在) |
不檢查管理員身份,允許任何 AD 員工登入 Client App |
| 管理員自刪 | DELETE /api/admin-manager/:uid 禁止刪除自己 |
白名單網域(config.frontendUrl) |
Microsoft 登入後會驗證 FRONTEND_URL 必須在 sso_allowed_list 中,否則 ?error=domain_not_allowed |
| Redirect URI 驗證 | authorize 的 redirect_uri 其 URL.origin 必須存在於該 App 的 redirect_uris[] |
| Logout redirect | origin 必須在全體 redirect_uris[] 或 FRONTEND_URL;驗證後僅保留 origin(剝除 path/query/fragment) |
| 項目 | 限制 |
|---|---|
OAuth state 驗證 |
timingSafeEqual 先比長度再比內容,不符即 ?error=invalid_state |
| Session fixation 防護 | Microsoft 登入成功後 req.session.regenerate() 重新產生 session ID |
| Auth code 重用 | Redis Lua script GET+DEL,第二次使用直接 401 Invalid or expired code |
| ERP 查詢失敗 | 不中斷登入,status = erp_not_found,erpData 為 null |
| Microsoft token exchange 失敗 | 寫入 sso_login_log status = failed + errorMessage,redirect ?error=token_exchange_failed |
| 環境 | 狀態 | Frontend URL | Backend URL |
|---|---|---|---|
| Prod | ✅ 線上 | https://df-it-sso-management.it.zerozero.tw |
https://df-it-sso-login.it.zerozero.tw |
| Test | ✅ 線上 | https://df-sso-management-test.apps.zerozero.tw |
https://df-sso-login-test.apps.zerozero.tw |
| Dev | 本機 | http://localhost:3000 |
http://localhost:3001 |
| Port 與 Service | 值 |
|---|---|
| SSO Frontend 本機 port | 3000 |
| SSO Backend 本機 port | 3001 |
| SSO Backend 容器 port(Test / Prod) | 35890 |
| Client App MockA / MockB 本機 port | 3100 / 3200 |
| 欄位 | 型態 | 說明 |
|---|---|---|
ppid |
SERIAL PK | 自動遞增主鍵 |
uid |
UUID | UUIDv7 |
domain |
VARCHAR(255) | 主要網域(UNIQUE when not deleted) |
name |
VARCHAR(255) | App 顯示名稱 |
description |
TEXT | 說明 |
app_id |
UUID | OAuth2 Client ID(自動產生,UNIQUE) |
app_secret |
VARCHAR(64) | OAuth2 Client Secret(自動產生,64 char hex) |
redirect_uris |
TEXT[] | 允許的 redirect_uri origins(本機 / test,最多 10 筆) |
frontend_url |
TEXT | App 前端 URL(Dashboard 顯示用,選填) |
backend_docs_url |
TEXT | App 後端 API 文件 URL(Dashboard 顯示用,選填) |
is_active |
BOOLEAN | 是否啟用 |
is_deleted |
BOOLEAN | 軟刪除 |
created_at / updated_at |
TIMESTAMPTZ | 含時區 |
白名單用途:
- OAuth2 授權 —
client_id查找 +redirect_uri的URL.origin必須在redirect_uris[] - Code exchange — 驗證
client_id+client_secret(timingSafeEqual) - CORS — 從所有 App 的
redirect_uris[]收集 origins +FRONTEND_URL(快取 60 秒) - Back-channel — 從
redirect_uris[]收集 origins + 對應的app_secret產生 HMAC - Redirect 驗證 — logout redirect 的 origin 必須在
redirect_uris[]中
限制: 每個 App 最多 10 筆 redirect_uris,僅允許 http: / https: 協定
| 欄位 | 型態 | 說明 |
|---|---|---|
ppid |
SERIAL PK | 自動遞增主鍵 |
uid |
UUID | UUIDv7 |
azure_oid |
VARCHAR(255) | Microsoft AD Object ID |
email |
VARCHAR(255) | 使用者 Email |
name |
VARCHAR(255) | 顯示名稱 |
erp_gen01 ~ erp_gen06 |
VARCHAR | ERP 員工資料 |
status |
VARCHAR(20) | success / failed / erp_not_found |
error_message |
TEXT | 錯誤訊息 |
ip_address |
VARCHAR(45) | 來源 IP |
user_agent |
TEXT | User Agent |
created_at / updated_at |
TIMESTAMPTZ | 含時區 |
| 欄位 | 型態 | 說明 |
|---|---|---|
ppid |
SERIAL PK | 自動遞增主鍵 |
uid |
UUID | UUIDv7 |
azure_oid |
VARCHAR(255) | Microsoft AD Object ID(首次登入後填入) |
email |
VARCHAR(255) | Email(UNIQUE when not deleted) |
name |
VARCHAR(255) | 顯示名稱 |
is_active |
BOOLEAN | 是否啟用 |
is_newer |
BOOLEAN | 是否尚未登入 |
is_deleted |
BOOLEAN | 軟刪除 |
通用 key-value 表,儲存可在 Dashboard 即時修改的 runtime 設定。目前只放 rate limit 四筆 seed,但表結構設計為通用,未來可追加其他類別(例如 session TTL、CORS 快取 TTL)而無需改 schema。
| 欄位 | 型態 | 說明 |
|---|---|---|
ppid |
SERIAL PK | 自動遞增主鍵 |
key |
VARCHAR(128) UNIQUE | 設定 key,以 {category}.{name} 命名(例如 rate_limit.global) |
value |
JSONB | 設定內容,格式由 category 決定 |
category |
VARCHAR(64) | 類別(目前:rate_limit) |
label |
VARCHAR(255) | 顯示用中文名稱 |
description |
TEXT | 顯示用說明 |
created_at / updated_at |
TIMESTAMPTZ | 含時區(BEFORE UPDATE trigger 自動更新 updated_at) |
Rate limit 設定格式:value = { "windowMs": <number>, "max": <number> },windowMs >= 1000、max >= 1。
Seed 四筆 key:rate_limit.global / rate_limit.auth / rate_limit.session / rate_limit.exchange。
| Key | TTL | Value | 說明 |
|---|---|---|---|
sso:session:{userId} |
24h | { userId, email, name, erpData, loginLogUid, loginAt } |
中央 session |
sso:code:{hex64} |
60s | { userId, email, name, erpData } |
一次性 auth code(Lua GET+DEL) |
sess:{sessionId} |
10min | Express session data | OAuth state + ssoRedirect |
| Method | Path | Rate Limit | 說明 |
|---|---|---|---|
| GET | /api/auth/sso/authorize |
30/15min | ?client_id=&redirect_uri= 授權入口 |
| POST | /api/auth/sso/exchange |
20/1min | { code, client_id, client_secret } 換 token |
| GET | /api/auth/sso/logout |
30/15min | 全域登出 + HMAC back-channel |
| Method | Path | Rate Limit | 說明 |
|---|---|---|---|
| GET | /api/auth/{authPath}/login |
30/15min | Microsoft OAuth 登入 |
| GET | /api/auth/{authPath}/redirect |
30/15min | OAuth 回調 |
| GET | /api/auth/me |
100/15min | 驗證 JWT + Redis(Cookie 或 Bearer) |
| POST | /api/auth/logout |
100/15min | 登出(Bearer + HMAC back-channel) |
| Method | Path | 說明 |
|---|---|---|
| GET | /api/allowed-list |
取得所有 App(secret 遮蔽為 app_secret_last4) |
| GET | /api/allowed-list/:uid |
取得單筆(secret 遮蔽) |
| POST | /api/allowed-list |
新增 App(自動產生 app_id + app_secret;可選 redirect_uris / frontend_url / backend_docs_url) |
| PUT | /api/allowed-list/:uid |
更新(domain / name / description / redirect_uris / is_active / frontend_url / backend_docs_url) |
| DELETE | /api/allowed-list/:uid |
軟刪除 |
| GET | /api/allowed-list/:uid/credentials |
取得完整 app_id + app_secret |
| POST | /api/allowed-list/:uid/regenerate-secret |
重新產生 app_secret |
| GET | /api/login-log |
登入紀錄搜尋(email / status / startDate / endDate / page) |
| GET | /api/admin-manager |
取得所有管理員 |
| GET | /api/admin-manager/:uid |
取得單筆管理員 |
| POST | /api/admin-manager |
新增管理員(僅需 email) |
| PUT | /api/admin-manager/:uid |
更新 email / is_active |
| DELETE | /api/admin-manager/:uid |
軟刪除(禁止刪除自己) |
| GET | /api/sso-setting |
取得全部系統設定(依 category + key 排序) |
| GET | /api/sso-setting/:key |
取得單筆設定 |
| PUT | /api/sso-setting/:key |
更新 value;rate_limit.* key 成功後會立即重建 rate limiter instance |
| Method | Path | 說明 |
|---|---|---|
| GET | /api/health |
PostgreSQL + Redis 狀態(任一失敗回 503 degraded) |
| GET | /api/docs |
Swagger UI |
| GET | /api/docs.json |
Swagger JSON |
| 變數 | 說明 |
|---|---|
PORT |
後端 Port(預設 3001) |
NODE_ENV |
development(本機)/ test(Test 容器)/ production(Prod 容器) |
FRONTEND_URL |
SSO Frontend URL(CORS 永遠允許,預設 http://localhost:3000) |
✱ SESSION_SECRET |
Express Session 密鑰 |
✱ JWT_SECRET |
JWT 簽名密鑰 |
JWT_EXPIRES_IN |
JWT 過期時間(預設 24h) |
✱ AZURE_CLIENT_ID / AZURE_CLIENT_SECRET / AZURE_TENANT_ID |
Azure AD 應用程式設定 |
✱ AZURE_REDIRECT_URI |
Microsoft OAuth callback URL;authPathSegment 會從路徑 /api/auth/{segment}/redirect 自動解析 |
COOKIE_DOMAIN |
共用 cookie domain(如 .apps.zerozero.tw;未設則僅限同 origin) |
ROPC_REDIRECT_URL |
管理員直接登入成功後的預設跳轉路徑(預設 /) |
✱ PG_DATABASE / PG_USER / PG_PASSWORD |
PostgreSQL 必填 |
PG_HOST / PG_PORT / PG_SCHEMA |
PostgreSQL 選填(預設 localhost / 5432 / public) |
REDIS_HOST / REDIS_PORT / REDIS_DB |
Redis 選填(預設 localhost / 6379 / 0) |
ERP_API_LOGIN_URL / ERP_API_SEARCH_URL / ERP_API_ACCOUNT / ERP_API_PASSWORD |
ERP 員工查詢(選填;未設則登入後 status=erp_not_found) |
SEQ_INGESTION_URL / SEQ_API_KEY |
Seq 結構化日誌 ingestion endpoint 與 API key(選填;未設則僅輸出 console) |
APP_NAME |
Seq 事件 Application 標籤(選填,預設 df-sso-backend) |
啟動時會檢查 ✱ 必填變數,缺任一項
process.exit(1)。
| 變數 | 說明 |
|---|---|
SSO_URL |
SSO Backend URL(server-side) |
SSO_APP_ID |
從白名單取得的 app_id(server-side) |
SSO_APP_SECRET |
從白名單取得的 app_secret(server-side,保密) |
APP_URL |
本 App URL(各環境各自設) |
NEXT_PUBLIC_SSO_URL |
SSO Backend URL(client-side) |
NEXT_PUBLIC_SSO_APP_ID |
同 SSO_APP_ID(client-side,公開) |
NEXT_PUBLIC_APP_URL |
同 APP_URL(client-side) |
| 層級 | 技術 |
|---|---|
| SSO Frontend | Next.js (App Router) + TypeScript + Tailwind CSS |
| SSO Backend | Node.js + Express.js |
| 安全 | Helmet + express-rate-limit + CORS 動態白名單 + HMAC-SHA256 + timingSafeEqual |
| 認證 | Microsoft Azure AD (OAuth 2.0 + @azure/msal-node) |
| 資料庫 | PostgreSQL 16(node-pg-migrate,dev/prod 雙目錄,見下) |
| Session / 快取 | Redis 7 (ioredis + connect-redis) |
| Token | JWT HS256(預設 24h) |
| API 文件 | Swagger(swagger-jsdoc + swagger-ui-express) |
| 部署 | Coolify + Docker Compose(Prod 與 Test 兩套獨立 stack;docker-compose-dev.yml 為本機開發樣板) |
DF-SSO/
├── backend/ # Express.js SSO Authorization Server
│ ├── server.js # Helmet / CORS / rate limit / session / routes
│ ├── config/ # index.js(env) / database / redis / msal / swagger
│ ├── middleware/adminAuth.js# JWT + Redis session + admin 白名單
│ ├── routes/ # auth / sso / allowedList / loginLog / adminManager
│ ├── services/ # allowedList / loginLog / adminManager / erpApi
│ ├── migrations/
│ │ ├── dev/ # Test / 本機 dev(歷史 12 筆 schema / seed / fix)
│ │ └── prod/ # Prod 乾淨 baseline(5 筆:init-schema + admin seed + allowed_list seed + sso_setting + frontend/docs URL)
│ └── sql/init.sql # postgres 容器 bootstrap(只啟用 pgcrypto,schema 交給 migration)
├── frontend/ # Next.js 管理後台
│ └── src/app/
│ ├── page.tsx # 登入首頁
│ ├── layout.tsx
│ └── dashboard/page.tsx # 單頁 Tab 切換(應用程式 / 登入紀錄 / 管理員)
├── microsoft-ad-login/ # Claude skill: SSO 整合器
├── docs/Design.md # 本文件
├── INTEGRATION.md # Client App 整合指引
├── README.md
├── docker-compose-dev.yml # 本機開發樣板
├── docker-compose-test.yml # ★ Coolify Test 環境部署
└── docker-compose-prod.yml # ★ Coolify Prod 環境部署
Prod 與 Test 為兩套獨立 Coolify stack,各自的 Postgres / Redis volume 也完全隔離(
sso-prod-*vssso-test-*)。
| 目錄 | 用途 | 使用者 |
|---|---|---|
| backend/migrations/dev/ | 本機開發與 Test 環境,保留完整歷史(12 筆)供除錯追蹤 | docker-compose-dev.yml / docker-compose-test.yml |
| backend/migrations/prod/ | 正式環境,乾淨 baseline(5 筆)確保首次部署無歷史包袱 | docker-compose-prod.yml |
Backend 容器啟動時會依 MIGRATIONS_DIR 環境變數挑資料夾:
# backend/Dockerfile CMD
npx node-pg-migrate -m migrations/${MIGRATIONS_DIR:-dev} up && node server.js| Compose 檔 | MIGRATIONS_DIR |
實際跑的 migration |
|---|---|---|
docker-compose-dev.yml |
dev |
backend/migrations/dev/ |
docker-compose-test.yml |
dev |
backend/migrations/dev/ |
docker-compose-prod.yml |
prod |
backend/migrations/prod/ |
本機手動操作時,請用對應環境的 script(不能再用舊的 migrate:up):
# Dev / Test
npm run migrate:up:dev
npm run migrate:down:dev
npm run migrate:create:dev <name>
# Prod
npm run migrate:up:prod
npm run migrate:down:prod
npm run migrate:create:prod <name>| 檔案 | 作用 |
|---|---|
1744200000000_init-schema.js |
建立 pgcrypto + uuidv7() + update_updated_at() + 三張表(sso_login_log / sso_allowed_list / sso_admin_manager),timestamp 一律 TIMESTAMPTZ + NOW(),sso_allowed_list 內建 app_id / app_secret / redirect_uris 欄位 |
1744200100000_seed-default-admin.js |
灌入預設管理員 jiaye.he@df-recycle.com(azure_oid c5e1e537-…)以便首次登入後台 |
1744200200000_seed-allowed-list.js |
灌入 SSO Management 自身白名單(https://df-it-sso-management.it.zerozero.tw);其他 Client App 由 Dashboard 新增 |
1744200300000_create-sso-setting.js |
建立 sso_setting 通用設定表 + seed 四筆 rate limit 預設值(rate_limit.global / auth / session / exchange) |
1744200400000_add-frontend-and-docs-url.js |
為 sso_allowed_list 新增 frontend_url / backend_docs_url 兩欄(Dashboard 顯示用,選填) |
- Schema 變更 — Dev 與 Prod 兩邊都要加;Dev 加到尾端,Prod 也加到尾端。未來兩邊的「init-schema」會逐漸分歧是可以接受的(Dev 留歷史、Prod 每隔一段時間可以 squash)
- Seed 變更 — 視目標環境只改對應資料夾
- down 絕不刪除 management domain 白名單資料 — 避免回滾毀掉管理後台自身的登入能力
- Seed 必須 idempotent — 一律
ON CONFLICT DO NOTHING