Skip to content

Latest commit

 

History

History
734 lines (591 loc) · 39.3 KB

File metadata and controls

734 lines (591 loc) · 39.3 KB

DF-SSO 系統設計文件

部署狀態: Prod 與 Test 環境皆已上線。

環境 Frontend Backend Compose Migrations
Prod https://df-it-sso-management.it.zerozero.tw https://df-it-sso-login.it.zerozero.tw docker-compose-prod.yml backend/migrations/prod/
Test https://df-sso-management-test.apps.zerozero.tw https://df-sso-login-test.apps.zerozero.tw docker-compose-test.yml backend/migrations/dev/
Dev http://localhost:3000 http://localhost:3001 docker-compose-dev.yml backend/migrations/dev/

目標

建立企業統一 SSO(Single Sign-On)單一登入系統:

  1. 各子專案透過 OAuth2 Client Credentialsapp_id + app_secret)接入 SSO 中央
  2. 登入 App-A → App-B 自動登入(中央 session 共享)
  3. 登出 App-A → SSO 刪除中央 session + back-channel 通知所有 App
  4. 每個 App 可註冊多個 redirect_uris(同一組 credentials 可橫跨多個 origin,例如 dev + prod)
  5. 子專案整合只需 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   │
          │ (員工資料)  │
          └────────────┘

SSO 核心流程

登入流程

使用者訪問 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 模式(嚴格模式至少要點一次按鈕)。兩種模式的取捨見「登入頁兩種模式」段。

登出流程(兩層 Session 模型)

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 真實狀態仍正確)。

登入頁兩種模式(契約 #3 細則)

每個 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 模式的取捨真相

Portal 模式的「跨 App 一鍵登入」與「登出視覺消失」是同一個機制的兩面

  • 中央 session 被刪後,AD session 還活著(DF-SSO 設計刻意不動 AD 那層)
  • Portal 模式自動 redirect → silent SSO 立刻補新 session → 看似沒登出
  • 中央 session 真的死了——舊的所有 token 都失效,使用者拿到的是新 session、新 cookie

接受這個取捨等於接受「內部使用者體感平順 > 登出視覺立即傳遞」這個價值排序。對外 App 不可選此模式。

模式 B(本地帳密 + SSO)下的 Portal

純 Mode A 可以無條件 auto-redirect。Mode B(App 同時有 SSO 使用者與本地帳號使用者)必須 provider-aware,否則本地帳號使用者會看不到帳密表單:

使用者狀態 登入頁應該做的事
從未登入過 / 不知 provider 顯示兩個選項:SSO 按鈕 + 本地帳密表單,自動 redirect
上次登入是 SSO(前端有 hint) auto-redirect 到 SSO(Portal 行為)
上次登入是 local(前端有 hint) 直接顯示帳密表單,走 SSO

實作建議:登入成功後寫一顆 非 httpOnly 的提示 cookie 或 localStoragelast_login_provider=sso|local(這個 hint 不是憑證、洩漏無害——只決定登入頁長什麼樣);登出時清掉,避免「我已經改用本地帳號了,但登入頁還一直 auto-redirect 到 SSO」卡住。

範本實作見 INTEGRATION.md。MockA / MockB 是嚴格模式參考;Coolify-API-Integration 是企業 Portal 模式參考。

兩種 401 情境 × 兩種模式對照

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 看到登出訊息」這一條最低限度視覺

Single Logout 加強模式(可選)

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.tsrecentlyLoggedOut 分支。

不實作不違反任何硬性契約——只是 Portal 模式的 UX 加強。嚴格模式天生不需要(登入頁顯示按鈕、silent re-auth 不自動觸發)。

設計取捨:DF-SSO vs Microsoft 365

「為什麼 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 Limiting

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 猜測

系統限制(Limits)一覽

時效限制

項目 位置
中央 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 MBexpress.json({ limit: '1mb' })
app_secret 長度 64 字元 hexcrypto.randomBytes(32)),exchange 先檢查長度再 timingSafeEqual
Auth code 長度 64 字元 hex,exchange 前嚴格比對長度
OAuth state 長度 64 字元 hex
Cookie sameSite laxsecure 僅在 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 約束(軟刪除時不衝突;建立時若存在軟刪除同名紀錄會自動恢復)

Rate Limit 限制

所有值由 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 存在 → emailsso_admin_manageris_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_uriURL.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_founderpDatanull
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

資料庫設計

sso_allowed_list(應用程式白名單)

欄位 型態 說明
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 含時區

白名單用途:

  1. OAuth2 授權client_id 查找 + redirect_uriURL.origin 必須在 redirect_uris[]
  2. Code exchange — 驗證 client_id + client_secrettimingSafeEqual
  3. CORS — 從所有 App 的 redirect_uris[] 收集 origins + FRONTEND_URL(快取 60 秒)
  4. Back-channel — 從 redirect_uris[] 收集 origins + 對應的 app_secret 產生 HMAC
  5. Redirect 驗證 — logout redirect 的 origin 必須在 redirect_uris[]

限制: 每個 App 最多 10 筆 redirect_uris,僅允許 http: / https: 協定

sso_login_log(登入紀錄)

欄位 型態 說明
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 含時區

sso_admin_manager(管理員)

欄位 型態 說明
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 軟刪除

sso_setting(系統動態設定)

通用 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 >= 1000max >= 1

Seed 四筆 key:rate_limit.global / rate_limit.auth / rate_limit.session / rate_limit.exchange


Redis 設計

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

API 端點

SSO 授權(OAuth2)

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

Auth 認證

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)

後台管理 API(皆須 adminAuth 中間件:JWT + Redis Session + 管理員白名單)

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 更新 valuerate_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

環境變數

SSO Backend(✱ = 必填)

變數 說明
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)

Client App

變數 說明
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-* vs sso-test-*)。


Migration 管理

雙目錄策略

目錄 用途 使用者
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/

NPM Scripts

本機手動操作時,請用對應環境的 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>

Prod baseline(migrations/prod/

檔案 作用
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 顯示用,選填)

寫新 Migration 原則

  1. Schema 變更 — Dev 與 Prod 兩邊都要加;Dev 加到尾端,Prod 也加到尾端。未來兩邊的「init-schema」會逐漸分歧是可以接受的(Dev 留歷史、Prod 每隔一段時間可以 squash)
  2. Seed 變更 — 視目標環境只改對應資料夾
  3. down 絕不刪除 management domain 白名單資料 — 避免回滾毀掉管理後台自身的登入能力
  4. Seed 必須 idempotent — 一律 ON CONFLICT DO NOTHING