跳至主要内容

Authentication Controller - 登入端點

概述

AuthController 提供帳密登入端點 POST /api/v1/auth/login:以 request body 的 username / password 換取短效 JWT access token,並由 Server 透過 HttpOnly Cookie 建立 Refresh Session。

本頁所有 /api/v1/... 都是 app-tenant-server reference implementation 的參考位址。 Capability 擁有認證機制與標準線格式;Feature 負責中性組裝;具體 URL、HTTP method、controller 與公開政策由 reference implementation 決定。下游改路徑時,必須一併修改 security matcher、 OpenAPI、client 與測試。詳見 Feature 設計與使用

不支援「Bearer token 登入」。「以現有 token 換新 token」在功能上就是 refresh,且該用 Refresh Credential(opaque、長命、少上線)而非 access token(短命、每請求上線)——讓 access token 能 續命等於讓一張外洩的 token 永不過期,摧毀短 TTL 的保證。刷新一律走專門的 POST /api/v1/auth/refresh(驗 server-side Refresh Session → 重載當前權限重鑄)。

API 端點

1. 登入端點

端點: POST /api/v1/auth/login

說明: 以帳密換取 token

請求標頭: 無需 Authorization header

請求體:

{
"username": "your-username",
"password": "your-password",
"rememberMe": true
}

rememberMe=true 會建立 30 天滑動期限的 persistent cookie;false 則使用 browser session cookie。 username 是全平台唯一定位鍵;tenant server 先取得 Account,再由 Account.tenantId 建立 principal 與 JWT tenant claim。Client 不傳 tenant。SUPER_ADMIN 雖歸屬 system Account, 仍投影為不帶 tenant claim 的 root principal。參考實作的 username trim 後不分大小寫,並以 固定 SHA-256 lookup key 隔離資料庫 collation 差異。

成功響應 (200 OK):

{
"accessToken": "new.jwt.token.here",
"user": { "id": "...", "username": "your-username" }
}

Response 同時帶 Set-Cookie: APP_SERVER_REFRESH=...; HttpOnly; SameSite=Lax; Path=.../api/v1/auth。 正式 HTTPS 環境必須啟用 Secure。Refresh Credential 不出現在 JSON,也不可由 JavaScript 讀取。 Cookie 名稱預設由 spring.application.name 推導(app-serverAPP_SERVER_REFRESH);scaffold retarget 後會自然取得專案專屬名稱,也可用 app.security.refresh-session.cookie-name 明示覆寫。

錯誤響應

錯誤一律為 RFC 7807 ProblemDetailProblemDetailFactory),錯誤訊息在 detail 欄位。

缺少憑證 (400 Bad Request):

{
"type": "urn:appfuse:error:validation-error",
"title": "Bad Request",
"status": 400,
"detail": "Missing login credentials",
"errorCode": "validation-error"
}

無效憑證 (401 Unauthorized):

{
"type": "urn:appfuse:error:bad-credentials",
"title": "Bad Credentials",
"status": 401,
"detail": "Password did not match for principal alice",
"errorCode": "bad-credentials",
"exceptionType": "org.springframework.security.authentication.BadCredentialsException",
"exceptionMessage": "Password did not match for principal alice",
"stackTrace": "..."
}

參考實作預設使用最大揭露政策,因此停用、鎖定、帳號過期、憑證過期、查無帳號與登入政策拒絕 各自保留 typed error code 與實際訊息。參考 server 若要求統一的 invalid-credentials contract,設定 app.api.error-disclosure.mode=minimal 即可;app.api.error-disclosure.max-stack-trace-chars 可調整 maximal 模式的診斷上限。

2. Token 刷新端點

端點: POST /api/v1/auth/refresh

說明: 專門用於刷新現有 token 的端點

Browser 會自動附上該應用的 HttpOnly Refresh Cookie;跨 origin 呼叫需使用 credentials: 'include'/Axios withCredentials: trueX-Refresh-Token bare credential 與 Authorization: Bearer 僅為遷移期 legacy fallback,不是新 client 的接線方式。

成功響應 (200 OK):

{
"accessToken": "refreshed.jwt.token.here",
"expiresIn": 900
}

Client 只把 access token 寫入記憶體。Server 會以 Set-Cookie 原子換成下一代 credential 並延續 cookie 的滑動期限。credential 採 client 不解析的秘密 selector + verifier,Server 只保存 hash;舊 verifier replay 會撤銷整個 Session family。頁面啟動時先呼叫 refresh bootstrap,再以新 access token 呼叫 /auth/me。access/refresh credential 都不得寫入 Web Storage。

同源多分頁必須以 Web Locks(framework 預設 lock name:appfuse:refresh-session)序列化 refresh; 鎖釋放代表上一分頁的 Set-Cookie 已完成,下一分頁才可使用共同 cookie 的新 generation。支援 strict rotation 的 Browser 部署必須使用 secure context 並把 Web Locks 納入瀏覽器基線;缺少 API 時 client 會記 warning。等待鎖預設最多 60 秒;取得鎖後 refresh 往返另有完整 15 秒,逾時會釋放鎖並走 Server availability,而不是要求重新認證或列出使用者無從對帳的 unknown mutation。

Web Lock 是 per-origin,因此一個 Refresh Session Cookie family 只能由一個 Browser origin 消費。 不同前端 origin 即使共用 API origin/Cookie Domain,也不能共享此 mutex;這類部署必須切分 Session family,不能直接沿用 strict replay policy。

本機開發若必須同時開啟不同 port 的 Office/Web,可在 Server 的外部 dev 設定使用:

app:
security:
refresh-session:
rotation-mode: reusable

reusable 保留 credential 並以原子 use 滑動 idle window,避免合法並行被 strict replay 撤銷;它會失去 Refresh Credential replay detection,因此 reference Server 只在 app.env=DEV 接受,SIT/UAT/正式 環境設定此值會啟動失敗。所有非開發環境維持預設 strict

錯誤響應 (401 Unauthorized):

{
"type": "urn:appfuse:error:refresh-session-invalid",
"title": "Unauthorized",
"status": 401,
"detail": "Refresh session idle window has expired: 7f...",
"errorCode": "refresh-session-invalid",
"exceptionType": "io.leandev.appfuse.security.auth.RefreshSessionRejectedException",
"exceptionMessage": "Refresh session idle window has expired: 7f...",
"stackTrace": "..."
}

stable refresh-session-invalid code 不變;最大揭露政策在 detail 與例外診斷中保留 missing、 not found、expired、revoked、eligibility、rotation/replay 與 policy rejection 的實際原因,最小 政策則收斂為通用訊息。認證依賴暫時不可用回 503 authentication-service-unavailable,未預期錯誤回 500 internal-error

M2M 憑證發放(client_credentials

機器客戶端(無終端使用者)以 client_id / client_secret 換取短效 access token。 操作與線格式由框架 capability ClientCredentialsService 承擔(ADR-025), 消費端的殼只決定路徑與錯誤方言。

端點POST /api/v1/auth/token(參考實作路徑;殼可自訂)

與登入的差異

面向登入M2M
資格判定主體必須可互動登入主體必須不可AuthPrincipal#interactiveLoginAllowed() 反向)
refresh token不發(RFC 6749 §4.4.3 SHOULD NOT)
sessionId/Session store——撤銷靠短效期與停用主體(M2M 無「登出」語意)
線格式無標準,歸消費端的殼RFC 6749,歸 capability

兩種請求方言

OAuth2 標準方言(任何標準 client library 皆可直接接入):

POST /api/v1/auth/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&client_id=svc_xxx&client_secret=sk_xxx

client 認證亦支援 client_secret_basic(RFC 6749 §2.3.1,AS MUST 支援):

Authorization: Basic base64(client_id:client_secret)

兩種方式不得並用(§2.3)→ 400 invalid_request

JSON 方言(相容分支,欄位名相同、置於 JSON body)——為既有消費端保留。

回應(兩方言相同,RFC 6749 §4.4.3)

{ "access_token": "eyJ...", "token_type": "Bearer", "expires_in": 300 }

錯誤

錯誤形狀依請求方言分流:form/Basic 進來回 {"error", "error_description"}(§5.2);JSON 進來維持 RFC 7807 ProblemDetail。最大政策的 error_descriptiondetail 是實際拒絕描述,並附 例外診斷;最小政策才把描述收斂為錯誤碼。

狀態error情況
401invalid_client查無/非 M2M 主體/停用/鎖定/密鑰錯/無租戶;描述預設保留實際原因,以 Basic 認證時附 WWW-Authenticate
400unsupported_grant_typegrant_type 非 client_credentials
400invalid_request缺參數,或兩種 client 認證方式並用
400invalid_scope帶了 scope——尚未支援,明確拒絕而非靜默忽略
429temporarily_unavailable限流(保護密碼雜湊的 CPU)

組裝(消費端)

capability 不做 autoconfiguration,由組態建立:

@Bean
public ClientCredentialsService clientCredentialsService(
AuthenticationManager m2mAuthenticationManager, // 是否含登入鎖定由你決定
JwtTokenProvider jwtTokenProvider,
AuthPrincipalLookup principalLookup,
ObjectProvider<ClientCredentialsRateLimiter> rateLimiter) {
return new ClientCredentialsService(m2mAuthenticationManager, jwtTokenProvider, principalLookup,
clientTokenExpirationMs,
Optional.ofNullable(rateLimiter.getIfAvailable()),
true); // requireTenant:租戶隔離部署為 true;ownership 取向傳 false
}
  • 憑證比對委派 AuthenticationManager:因而取得認證事件與統一稽核。是否對 M2M 套用登入鎖定是產品決策(鎖定服務帳號可能被外部誤設定觸發、造成整合中斷),框架不預設——參考實作另建一個不含鎖定的 manager。
  • 限流未提供時建構期會發 WARN:公開端點無限流即為 CPU 耗盡面。
  • 憑證產生ClientCredentialsFactorySecureRandom + url-safe base64、secret 256 bits);唯一性重試留消費端(只有持久層知道)。

API key(做不了 token exchange 的外部系統)

有些外部系統無法先換 token 再呼叫。api-key 讓它們以靜態 key 直接呼叫——但它是同一個 服務帳號身分的第二種憑證呈遞,不是繞過租戶/權限鏈的旁路:認證後放進 SecurityContext 的是 同一個 AuthPrincipal,走完全相同的鏈。

  • 預設關閉app.security.api-key.enabled):未啟用即不註冊 filter、任何 key 皆無效。
  • 以請求標頭 X-Api-Key: <key> 呈遞;無此標頭的請求完全不受影響(bearer 路徑照舊)。
  • 只簽給服務帳號(非互動主體);真人帳號不得以 api-key 呈遞。
  • 雜湊用 SHA-256 而非 bcrypt:key 為 256 bits 隨機、無字典攻擊面,而 api-key 逐請求呈遞, 慢雜湊會是自我 DoS。只存雜湊,明文只在簽發時回傳一次。
  • 撤銷即刻生效(每請求重新查找)——無需等 token 過期,這是相對 JWT 的優勢。
  • 一個帳號可持多把 key:零停機輪替的前提(簽新 → 部署 → 撤舊)。

管理(reference implementation 的參考位址):POST /api/v1/service-accounts/{id}/api-keys 簽發(回一次性明文)、 GET 列出 metadata(永不含明文或雜湊)、DELETE .../{keyId} 撤銷。

# 簽發(需管理員 bearer)
curl -X POST http://localhost:8080/api/v1/service-accounts/{id}/api-keys \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"label":"prod-etl"}'
# → { "keyId": "...", "apiKey": "ak_...", "label": "prod-etl", ... }

# 外部系統以 key 直接呼叫
curl http://localhost:8080/api/v1/products -H "X-Api-Key: ak_..."

登出與 Token 撤銷(黑名單)

JWT 是無狀態的——簽發後在過期前皆有效,後端無法單靠驗證讓它「提早失效」。為支援登出,框架提供 Token 黑名單機制:登出時把 token(或其 session ID)記入黑名單,後續請求在 JWT 驗證之前先檢查黑名單,命中即拒絕。

流程

POST /api/v1/auth/logout (Authorization: Bearer <token>)

tokenBlacklistStore.add(token) ← 將 token 記入黑名單

後續請求 → TokenBlacklistFilter ← 在 JWT 驗證前先查黑名單
↓ (命中黑名單)
401 Unauthorized (RFC 7807) ← 直接拒絕,不再進入 JWT 驗證

TokenBlacklistFilter 註冊在標準 oauth2ResourceServer().jwt() 的 bearer 驗證之前執行(ADR-009 雙模式資源伺服器);命中黑名單時直接回 401,被撤銷的 token 即使尚未到期也無法再使用。

登出端點

@PostMapping("/logout") // 類別 @RequestMapping("/api/v1/auth")
public ResponseEntity<?> logout(
@RequestHeader(value = "Authorization", required = false) String authHeader) {
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
tokenBlacklistStore.add(token); // 加入黑名單
}
return ResponseEntity.ok().build();
}

參考實作將 header 設為 required = false,且 POST /api/v1/auth/logout 明確列入匿名白名單: 未帶 token 時仍回成功,讓 access token 已過期的前端可以冪等清除本地 session。若請求帶著 無效或過期 Bearer token,標準 resource-server filter 仍可能在 controller 前回 401permitAll 不會略過 Bearer token 驗證。

TTL 與記憶體

黑名單記錄無需永久保留——token 自然過期後就再也不會通過驗證。因此底層 CacheTTL 應與 access token 過期時間一致:記錄在 token 過期後自動清除,黑名單不會無限增長。TTL 由 Cache 設定(CacheTokenBlacklistStore 的建構子接受預先配置好的 Cache<String, Boolean>,見 Security API 參考),add 方法本身不帶 TTL 參數。

與登入鎖定共用快取技術棧

Token 黑名單與登入鎖定(見 Security 使用指南)使用相同的 AppFuse Cache 機制(皆透過 CacheBuilder 建立、支援 TTL 自動過期與統一監控),未來可一併替換為 Redis 以支援分散式部署。

Session family 與 Access Token 即時撤銷

Refresh Credential 對應 app-owned RefreshSession row;登出時撤銷持久 Session family,之後 refresh 即被拒絕。同時把目前 access token 的 sessionId 加入短效 blacklist,使尚未自然過期的 access token 立即失效。Blacklist 不再承擔 Refresh Session 的持久狀態。

使用範例

JavaScript/Fetch API

帳密登入

const login = async (credentials) => {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
});
if (response.ok) {
const data = await response.json();
return data.accessToken;
}
const error = await response.json();
throw new Error(error.detail ?? error.error);
};

login({ username: 'user', password: 'pass' }).then(token => {
console.log('Login successful:', token);
});

專門的 Token 刷新

const refreshSession = async () => {
const response = await fetch('/api/v1/auth/refresh', {
method: 'POST',
credentials: 'include'
});

if (response.ok) {
const data = await response.json();
return { accessToken: data.accessToken };
} else {
const error = await response.json();
console.error('Refresh failed:', error.detail);
throw new Error(error.detail);
}
};

cURL 範例

帳密登入

curl -c cookies.txt -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"your-username","password":"your-password","rememberMe":true}'

Token 刷新

# 專門的 token 刷新
curl -X POST http://localhost:8080/api/v1/auth/refresh \
-b cookies.txt

Browser Session 安全界線

  • Access Token 僅存於記憶體,每個 API request 以 Authorization: Bearer 傳送。
  • Refresh Credential 僅存於 HttpOnly Cookie,Server 資料庫只保存 SHA-256 hash。
  • 401 refresh-session-invalid 才要求重新認證;refresh 的 5xx503 必須保留目前登入狀態。
  • Logout 撤銷 Server Refresh Session family、清除 cookie,並立即使目前 access family 失效。

測試

專案測試涵蓋登入、cookie bootstrap、滑動 idle、logout revoke、錯誤分類,以及 access token 刷新後的權限重載。

  • 缺少憑證的錯誤場景
  • 無效 token 的錯誤場景
  • Token 刷新功能測試