跳至主要内容

Server 對外 URL

概述​

io.leandev.appfuse.web.url 提供對外基底 URL 的驗證與組合能力。反向代理可以把外部 /gateway/ 映射到內部 /app-server/;寄出的簽章連結、OIDC callback 與 Cookie Path 必須使用瀏覽器看到的路徑。內部 Servlet context 仍負責 Server 路由。

採用前請確認依賴版本已包含 PublicServerUrl,並 pin 已發布的框架版本。

快速開始​

import io.leandev.appfuse.web.url.PublicServerUrl;
import io.leandev.appfuse.web.url.PublicServerUrlContext;
import io.leandev.appfuse.web.url.PublicServerUrlResolver;
import java.util.Map;

var configured = PublicServerUrl.of("https://api.example.com/gateway/");
PublicServerUrlResolver resolver = context -> configured;
var url = resolver.resolve(PublicServerUrlContext.unspecified());
url.cookiePath(); // /gateway
url.path("/api/v1/auth"); // /gateway/api/v1/auth
url.uri("/api/v1/auth/link/consume", Map.of("token", "a+b"));
// https://api.example.com/gateway/api/v1/auth/link/consume?token=a%2Bb

resolver 由應用組裝成 bean;框架不讀取設定、不取得 request,也不判斷租戶或授權。 背景工作和 HTTP 流程使用同一個 resolver。

功能說明​

基底必須是完整 HTTP(S) URL,不能包含 userinfo、query、fragment、dot segment、重複 斜線、Cookie 分隔符分號或編碼分隔符。scheme/host 大小寫、預設 port、尾端單一斜線及 unreserved percent encoding 會正規化;Unicode 路徑輸出為 ASCII percent encoding。

endpoint 與動態 segment 傳未編碼值,query 由 API 編碼一次。動態 segment 拒絕斜線、 反斜線、百分號與 dot segment;不能傳入另一個完整 URL。此能力不保證目的地可信, 受信任來源仍由應用控制。

業務目標如 /orders/123、API 的 /api/v1/... 圖片/下載路徑保留原契約。 前端 httpClient 對 API 相對路徑套用一次 API base;既有 LinkBuilder 也維持相對 href。 S3 等儲存供應者簽出的 URL 使用儲存端的位址與簽章機制。

配置選項​

參考實作的固定 resolver 在非 DEV 環境必須明示:

app.public-base-url=https://api.example.com/gateway
server.servlet.context-path=/app-server

例如代理 /gateway/ → /app-server/,Session Path 為 /gateway,refresh Path 為 /gateway/api/v1/auth,OIDC callback 為 https://api.example.com/gateway/login/oauth2/code/{registrationId}。 root 對外入口設為 https://api.example.com,Session Path 才是 /。 僅 app.env=DEV 且 URL 未設定或空白時,使用本機預設: http://localhost:{server.port}{server.servlet.context-path}(port 缺省 8080、context 缺省空字串; server.ssl.enabled=true 時改為 HTTPS)。參考實作為 http://localhost:8080/app-server。 ./gradlew bootRun 已預設提供 app.env=DEV;IDE/直接啟動須自行明示,僅啟用 Spring dev profile 不算。 明示 URL 優先且仍驗證格式;使用代理、tunnel、非 localhost 入口或 server.port=0/-1 時須自行設定。 其他環境(含未設定 app.env)沒有 localhost 部署預設;缺值於啟動時失敗。

消費端設定/來源
Refresh Cookie、OIDC Session Cookie、OIDC callback、SignedLink consume、Actuator discoveryapp.public-base-url
SignedLink 消費後的前端落點app.security.signed-link.redirect-base-url,獨立前端 URL
OIDC 成功後的前端頁面app.security.oidc.success-uri,獨立前端 URL
前端 API base部署的前端 app.baseURL,須對齊對外 API 入口
內部 Controller/security matcherServer 內部路由,不加入 public prefix

Spring OIDC 的標準 redirect-uri 模板由 canonical URL 展開;與 canonical callback 不符 即拒絕啟動。自訂 ClientRegistrationRepository 須同時實作 Iterable,讓啟動驗證列舉 所有註冊;不能只依 runtime callback 覆寫略過檢查。設定 IdP 的 allowlist 時使用最終展開值。 參考接線保留原 state/nonce/PKCE。 Boot 4 使用 spring-boot-starter-session-jdbc 與 DefaultCookieSerializerCustomizer; Session 接線由 optional oidc-login 擁有,位於 feature/oidclogin/SessionInfrastructureConfig.java。OAuth2 Client 與 Session JDBC starter 一同宣告在 gradle/features/oidc-login.gradle.kts,由 feature catalog 登記為 defaultable seam; 主建置檔載入 gradle/features/*.gradle.kts。provision OIDC 時帶入,未安裝時不帶入。 auth 的 JPA refresh token 紀錄與 mail 的 OAuth2 郵件驗證不依賴 HTTP Session。 已安裝 OIDC 後,營運期 app.security.oidc.enabled=false 不移除套件或 Session 接線; 安裝範圍與營運開關分開管理。 @SpringSessionDataSource 的 DelegatingDataSource 使用同一連線池、獨立 transaction resource key; Session repository 與具名 JDBC transaction operations 皆使用該 wrapper,避免沿用並提交 JPA 交易中的連線。只另建交易管理器並指定 REQUIRES_NEW 並不足以隔離。

OIDC feature 預設 spring.session.jdbc.initialize-schema=always:應用啟動時,Spring Boot 對支援的資料庫執行套件內的標準 SQL,建立 SPRING_SESSION、SPRING_SESSION_ATTRIBUTES 及相關索引/外鍵;資料庫帳號須有建表權限。這是 runtime 初始化,不是 provision 工具連線建表。 每次啟動都會嘗試,沿用 Spring 預設 continue-on-error=true,既有表不會因此清空;但其他 DDL 錯誤也可能被忽略,不能把啟動成功當成 schema 已完備。此機制不升級既有表格結構。

若這兩張表已由 Flyway/DBA 管理,在外部 {serviceName}.properties 設定:

spring.session.jdbc.initialize-schema=never

此時由原有 migration 流程建表與升級,避免兩套建表機制並行;保留明示的 operator override。 JPA 交易持有連線時首次存取 Session,同一請求會同時占用池內兩條連線;請依並行交易量 預留 Session 所需容量,避免所有連線被外層交易占滿後互相等待。

設定情況啟動診斷與處理
非 DEV 缺少新 URL(即使有舊 link-base-url)app.public-base-url is required unless app.env=DEV.;明示新值。有效舊值另提示 Suggested app.public-base-url from legacy setting:,不自動採用
新 URL 非法app.public-base-url must be a valid absolute HTTP(S) server base URL;修正完整 URL
cookie-path-prefix 非空Remove app.security.refresh-session.cookie-path-prefix; configure app.public-base-url with the complete public path;移除舊值,否則拒絕啟動
cookie-path-prefix 空值忽略
舊 link-base-url 與新 URL 正規化後相同警告 app.security.signed-link.link-base-url is deprecated; remove it and retain app.public-base-url;移除舊 key
舊 link-base-url 不同app.security.signed-link.link-base-url conflicts with app.public-base-url; migrate the legacy setting;先統一位址
舊 link-base-url 非法Invalid legacy app.security.signed-link.link-base-url; migrate to app.public-base-url;移除或修正舊值

升級既有專案時,先複製並調整 package:config/PublicServerUrlConfig.java、 config/PublicActuatorLinksConfig.java;這些是 skeleton 接線,不會由 /feature upgrade 自動補齊。 已安裝 OIDC 者,將舊 config/SessionInfrastructureConfig.java 搬至 feature,並採用專屬 Gradle script、移除主建置檔的重複相依;不得同時留下兩份 Session bean。 未安裝 OIDC 且應用沒有 HTTP Session 用途者,移除舊 Session 接線、專屬測試、兩個 starter 及直接宣告的 spring-session-jdbc,保留 SignedLink/refresh/Actuator 測試。 既有 SQL migration 與資料表保留,不因移除 runtime dependency 改寫歷史 checksum 或刪表。 新安裝 OIDC 預設自動初始化 Session schema;若選用 Flyway/DBA 管理,覆寫為 never, 並依應用自己的 migration 序列備妥標準 schema,不套用來源參考實作的 migration 版號。 既有 OIDC 下游若仍保留片段中的 embedded,採納新預設;外部設定若有明示值則保留並確認 其建表責任。未安裝 OIDC 的下游不新增片段、套件或資料表。 同步 consumer 與主設定檔的 app.public-base-url/@conf-env type(url) 宣告,再重跑各已使用 tier 的 /env-config dev、/env-config test、/env-config prod。dev 可留空使用上述本機預設;其他必要參數仍須準備。

在已發布的新依賴與接線下執行 ADR-034 驗收矩陣 的 T1–T6:根路徑、相同 context、增加/替換/移除 prefix 與多段 prefix。檢查 Cookie 實際 送回、OIDC callback、SignedLink 背景連結及原有 401/403;資源路徑只套一次 API base。 參考矩陣使用 server.forward-headers-strategy=none;其他策略須在採用環境另行驗證。 重用瀏覽器 fixture 時,可用 -Dpublic-url.playwright-module=/absolute/path/node_modules/playwright 指定 Playwright 安裝位置;執行方式及證據界線見 ADR 的本機測試證據。

進階用法:部署切換與回復​

以下步驟供採用專案在規劃部署時執行。

  1. 保存舊 artifact、外部設定、代理路由及 IdP callback 設定;記錄前端 API base、 Cookie 名稱/Path/Domain、Session 撤銷策略與仍有效連結的最晚到期時間。
  2. 以新 canonical URL 更新所有 Server consumer 與代理/IdP/前端設定。 不以 Forwarded header 推導 canonical URL,也不讓代理再重寫相同的 Location/Cookie Path。
  3. Path 改變時安排重新登入。舊 Path / 等仍可能匹配新路徑;有重疊時設定新的 app.security.refresh-session.cookie-name 與 server.servlet.session.cookie.name。 未知舊 Path 的 Cookie 不保證可清除,重新登入也不代表舊 credential 已撤銷。
  4. 若部署使用 mock auth,同步名稱契約:前端 mock adapter、Vite mock-auth plugin 與 Host 的 MockAuthSessionController 接線。預設名稱為 APP_SERVER_REFRESH;若啟用 mock auth,須核對 Host 的實際掛載路徑與開關,不能沿用另一環境的 Cookie 契約。
  5. 保存已寄送通知與 Outbox 的完整 URL 快照。舊入口保留至既有 token 到期,轉送時 保留 query 且避免記錄 token;不改寫快照、不把已過期 token 當成重新簽發。
  6. 回復時一起回復 artifact、設定、代理與 IdP;依演練結果保留新入口相容路由,處理 切換後新 Cookie/新發連結。以有效與過期連結、舊/新名稱 Cookie 分別驗證。

應用正式切換前須在實際部署環境演練上述流程,記錄 artifact 雜湊、設定版本、測試結果 與回復後的 credential 狀態。本機代理測試不能取代 WAR/Tomcat、正式 IdP、外部資料庫、 多節點及真實通知的切換/回復演練。

PublicServerUrlContext 保留顯式 scope 擴充點。固定 resolver 接線使用單一 URL;同一 Server 多租戶各自網域仍需額外設計受信任網域目錄、登入前租戶辨識及背景租戶傳遞。

API 參考​

API用途
PublicServerUrl.of(String)/PublicServerUrl(URI)驗證並建立不可變基底
cookiePath()Session 的對外 Path,root 為 /
path(endpoint, segments...)對外絕對路徑,不含 origin
uri(endpoint, segments...)完整 Server URI
uri(endpoint, query, segments...)完整 URI 與編碼後 query
PublicServerUrlResolver.resolve(context)由應用明示選擇基底
PublicServerUrlContext.unspecified()單一部署不指定 scope

參見 LinkBuilder、SignedLink、 Notification 與 ADR-034。