公開發布說明:本文中的網域、手機號碼、OTP、Token 與密鑰值均為占位示例,不可直接用於正式環境。
目標受眾:需要與 AuthServer(統一登入認證中心)對接的後端資源服務、BFF 層開發者。 AuthServer 版本:基於
springcloud-aile/authserver原始碼分析(release分支)。
┌─────────────────────────────────────────────────────┐
│ AuthServer (:8080) │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ │
│ │ JWT 簽發 │ │ Token 驗證 │ │ 信任事件 │ │
│ │ (RS256) │ │ (introspect) │ │ (webhook) │ │
│ └──────┬──────┘ └──────┬───────┘ └──────┬──────┘ │
│ │ │ │ │
│ ┌──────┴────────────────┴──────────────────┴──────┐ │
│ │ JWKS 公鑰分發 │ │
│ │ GET /oauth2/jwks │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
│ ▲ ▲
│ 登入 → JWT │ Token 驗證 │ 信任事件推送
▼ │ │
┌──────────┐ ┌──────────┴──┐ ┌───────┴──────────┐
│ 客戶端 │ │ 資源服務 │ │ 資源服務 webhook │
│ (BFF/App) │ │ (SpringCloud)│ │ (接收信任事件) │
└──────────┘ └─────────────┘ └──────────────────┘| 特徵 | 說明 |
|---|---|
| 簽名演算法 | RS256(RSA 2048-bit 金鑰對) |
| Token 格式 | JWT(Header: {"alg":"RS256","type":"oauth2","kid":"<SHA-256>"} |
| Token 傳輸 | HTTP Header satoken: <jwt_string> |
| 公鑰分發 | GET /oauth2/jwks 回傳 JWKS 格式公鑰 |
| Token 有效期 | 預設 30 天(jwt.expiration: 2592000 秒) |
| Token 撤銷 | 支援 jti 級別和 accountId 級別黑名單 |
| 客戶端認證 | OAuth Client(clientId + 可選 clientSecret) |
{
"alg": "RS256",
"type": "oauth2",
"kid": "<SHA-256 of public key modulus, base64url>"
}| Claim | 型別 | 必填 | 說明 |
|---|---|---|---|
iss | string | ✓ | 簽發者,預設 http://localhost:8080,正式環境應設定為 AuthServer 域名 |
sub | string | ✓ | 帳號唯一標識(accountId) |
mobile | string | ✓ | 手機號碼(不含國家區號) |
countryCode | string | 國家區號,如 +886 | |
iat | number | ✓ | 簽發時間(Unix epoch 秒) |
exp | number | ✓ | 過期時間(Unix epoch 秒) |
jti | string | ✓ | JWT 唯一 ID(UUID 格式),用於精確撤銷 |
login_method | string | 登入方式:3=OTP, 4=PASSWORD, 2=CHANNEL, 7=QRCODE | |
channel | string | 應用渠道(來自 deviceData.channel 列舉值) | |
thirdChannel | string | 第三方登入渠道(僅第三方登入 token 包含) | |
scopeId | string | 第三方渠道 scopeId(僅第三方登入 token 包含) | |
rememberMe | boolean | ✓ | 是否記住此裝置登入 |
預設 30 天,可透過設定調整:
# AuthServer 設定
jwt:
expiration: 2592000 # 秒,預設 30 天建議:資源服務的本地快取/驗證邏輯應容忍 Token 過期時間的合理時鐘偏差(建議 ±5 分鐘)。
資源服務:指需要驗證請求方 JWT Token 的後端微服務(如
aile-service-account、aile-service-room等)。
| 模式 | 適用場景 | 複雜度 |
|---|---|---|
| A. 本地驗證 | 高吞吐服務,避免每次請求都調 AuthServer | 中等 |
| B. 遠端 Introspect | 簡單整合,可容忍一次額外 HTTP 呼叫 | 低 |
| C. 混合模式 | 本地驗證 + 信任事件訂閱,兼顧效能與安全性 | 較高 |
原理:資源服務從 JWKS 端點取得 AuthServer 公鑰,在本地驗證 JWT 簽名和有效期。
curl -s https://auth.example.com/oauth2/jwks | jq '.'響應示例:
{
"success": true,
"data": {
"keys": [
{
"kty": "RSA",
"kid": "abc123...base64url",
"use": "sig",
"alg": "RS256",
"n": "<modulus base64url>",
"e": "<exponent base64url>"
}
]
},
"requestId": "..."
}Spring Boot 實現(推薦 Filter/Interceptor 模式):
// 1. 定義 JWKS 快取設定
@Configuration
public class AuthServerJwksConfig {
@Value("${auth.server.jwks-uri:http://authserver/oauth2/jwks}")
private String jwksUri;
@Bean
public RSAPublicKey authServerPublicKey() {
// 從 JWKS 端點取得並快取公鑰
// 建議快取 TTL = 1 小時,定期重新整理
JWKSet jwkSet = fetchJwks(jwksUri);
return extractRsaPublicKey(jwkSet);
}
}
// 2. 實現認證攔截器
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) {
String token = request.getHeader("satoken"); // 與 AuthServer 一致的 header 名
if (token == null) {
response.setStatus(401);
response.getWriter().write("{\"error\":\"MISSING_TOKEN\"}");
return;
}
try {
// 解析並驗證 JWT
JWT jwt = JWTUtil.parseToken(token);
// 使用公鑰驗證簽名
boolean valid = jwt.setSigner(JWTSignerUtil.createSigner("RS256", publicKey)).verify();
if (!valid) {
throw new JWTException("簽名無效");
}
// 檢查過期
JSONObject payload = jwt.getPayloads();
long exp = payload.getLong("exp", 0);
if (exp < System.currentTimeMillis() / 1000) {
throw new JWTException("令牌已過期");
}
// 提取使用者資訊
String accountId = payload.getStr("sub");
request.setAttribute("accountId", accountId);
request.setAttribute("tokenClaims", payload);
chain.doFilter(request, response);
} catch (Exception e) {
response.setStatus(401);
response.getWriter().write("{\"error\":\"INVALID_TOKEN\"}");
}
}
}# 與 AuthServer 設定保持一致,預設為 satoken
sa-token.token-name=satoken非 Java 實現參考:
Python(PyJWT + cryptography):
import jwt
import requests
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
import base64
import json
class AuthServerValidator:
def __init__(self, jwks_url, issuer):
self.jwks_url = jwks_url
self.issuer = issuer
self._public_keys = {} # kid -> public_key
def _fetch_jwks(self):
resp = requests.get(self.jwks_url)
jwks = resp.json()["data"]
for key in jwks["keys"]:
kid = key["kid"]
# 將 JWK 轉為 PEM
from cryptography.hazmat.primitives.asymmetric import rsa
n = int.from_bytes(base64.urlsafe_b64decode(key["n"] + "=="), "big")
e = int.from_bytes(base64.urlsafe_b64decode(key["e"] + "=="), "big")
public_key = rsa.RSAPublicNumbers(e, n).public_key(default_backend())
self._public_keys[kid] = public_key
def validate(self, token: str) -> dict:
# 先解析 header 取得 kid
unverified = jwt.get_unverified_header(token)
kid = unverified.get("kid")
if kid not in self._public_keys:
self._fetch_jwks()
public_key = self._public_keys[kid]
claims = jwt.decode(
token,
public_key,
algorithms=["RS256"],
issuer=self.issuer,
options={"verify_exp": True}
)
return claimsGo(golang-jwt):
import (
"crypto/rsa"
"encoding/base64"
"math/big"
"github.com/golang-jwt/jwt/v5"
)
func validateToken(tokenString string, publicKey *rsa.PublicKey, issuer string) (*jwt.MapClaims, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return publicKey, nil
},
jwt.WithIssuer(issuer),
jwt.WithLeeway(5*time.Minute), // 5 分鐘時鐘偏差容忍
)
if err != nil {
return nil, err
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token")
}
return &claims, nil
}Node.js(jsonwebtoken + jwks-rsa):
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
const client = jwksClient({
jwksUri: 'https://auth.example.com/oauth2/jwks',
cache: true,
cacheMaxEntries: 5,
cacheMaxAge: 3600000, // 1 小時
});
function getKey(header: jwt.JwtHeader, callback: jwt.SigningKeyCallback) {
client.getSigningKey(header.kid, (err, key) => {
if (err) return callback(err);
callback(null, key.getPublicKey());
});
}
export async function validateToken(token: string): Promise<jwt.JwtPayload> {
return new Promise((resolve, reject) => {
jwt.verify(token, getKey, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com',
}, (err, decoded) => {
if (err) return reject(err);
resolve(decoded as jwt.JwtPayload);
});
});
}適用場景:不想管理公鑰快取,每次驗證都調 AuthServer。
資源服務 ──GET /oauth2/introspect──▶ AuthServer
│ Header: satoken: <jwt>
│
◀── 200 { active: true/false, sub, exp, iat, jti }# 驗證 Token
curl -s https://auth.example.com/oauth2/introspect \
-H "satoken: <使用者的JWT>" | jq '.'響應:
{
"success": true,
"data": {
"active": true,
"sub": "acc_xxx",
"iss": "http://localhost:8080",
"exp": 1750000000,
"iat": 1720000000,
"jti": "uuid-xxx"
},
"requestId": "..."
}注意:Introspect 端點本身需要攜帶有效的
satoken頭。對於服務間呼叫,建議使用模式 A(本地驗證)+ 模式 C(信任事件),而不是讓每個資源服務都持有一個"服務帳號"的 Token 去做 introspect。
推薦用於正式環境。在模式 A 的基礎上增加 Token 撤銷事件的實時感知。
curl -s -X POST https://auth.example.com/internal/v1/resource-servers/register \
-H "X-AS-Registration-Token: <registration-token>" \
-H "Content-Type: application/json" \
-d '{
"serviceCode": "aile-service-account",
"serviceName": "Account Service",
"webhookUrl": "https://account-service.example.com/auth/events",
"webhookSecret": "<webhook-hmac-secret>",
"eventTypes": ["TokenInvalidated", "AccountTokensInvalidated", "ResourcePublicKeyRemoved"],
"enabled": true
}'@RestController
@RequestMapping("/auth/events")
public class AuthEventController {
@Value("${auth.server.webhook.secret}")
private String webhookSecret;
@PostMapping
public ResponseEntity<Void> handleEvent(
@RequestHeader("X-AS-Event-Id") String eventId,
@RequestHeader("X-AS-Event-Type") String eventType,
@RequestHeader("X-AS-Timestamp") String timestamp,
@RequestHeader("X-AS-Signature") String signature,
@RequestBody String body
) {
// 1. 驗證 HMAC 簽名
String expectedSig = hmacSha256(webhookSecret, timestamp + "." + body);
if (!MessageDigest.isEqual(expectedSig.getBytes(), signature.getBytes())) {
return ResponseEntity.status(403).build();
}
// 2. 處理事件
JsonNode event = objectMapper.readTree(body);
switch (eventType) {
case "TokenInvalidated":
String jti = event.get("jti").asText();
localBlacklist.add(jti); // 加入本地黑名單
break;
case "AccountTokensInvalidated":
String accountId = event.get("accountId").asText();
accountBlacklist.add(accountId);
break;
case "ResourcePublicKeyRemoved":
refreshPublicKeyCache(); // 重新整理公鑰快取
break;
}
return ResponseEntity.ok().build();
}
private String hmacSha256(String secret, String data) {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return Base64.getEncoder().encodeToString(mac.doFinal(data.getBytes(StandardCharsets.UTF_8)));
}
}// 在 JwtAuthenticationFilter 中增加黑名單檢查
if (localBlacklist.contains(jti) || accountBlacklist.contains(accountId)) {
response.setStatus(401);
response.getWriter().write("{\"error\":\"TOKEN_REVOKED\"}");
return;
}資源服務需要知道何時 Token 被撤銷。兩種方式:
| 方式 | 機制 |
|---|---|
| 主動查詢 | 呼叫 GET /oauth2/introspect,檢查 active 欄位 |
| 被動接收 | 註冊 webhook,接收 TokenInvalidated / AccountTokensInvalidated 事件 |
AuthServer 的 Token 黑名單儲存在 Redis 中,資源服務無法直接存取。建議在資源服務側維護一個程序內(或本地快取)的黑名單,透過 webhook 事件來更新。
BFF(Backend For Frontend) 層:面向客戶端的 API 閘道器或聚合層,負責接收客戶端請求、管理會話、轉發給下游資源服務。
客戶端 App / Web
│
▼
┌──────────────────────┐
│ BFF (aile-gateway │
│ 或 自建 BFF) │
│ │
│ ① 管理使用者登入會話 │
│ ② 透傳 satoken 到 │
│ 下游資源服務 │
│ ③ 呼叫 AuthServer │
│ 登入/驗證 API │
└──────┬───────────────┘
│ Feign / HTTP + satoken header
▼
┌──────────────┐
│ 資源服務 │
│ (本地驗證JWT) │
└──────────────┘# Step 1: 傳送 OTP(取得 onceToken)
curl -s -X POST https://auth.example.com/api/v1/otp/send \
-H "Content-Type: application/json" \
-d '{
"countryCode": "<country-code>",
"mobile": "<mobile-number>"
}'
# 響應:{ "data": { "onceToken": "ot_xxx" } }
# Step 2: 使用 OTP + onceToken 完成登入
curl -s -X POST https://auth.example.com/api/v1/login/otp \
-H "Content-Type: application/json" \
-H "deviceData: {...}" \
-d '{
"onceToken": "ot_xxx",
"otp": "<otp-code>",
"clientId": "aile-web"
}'
# 響應:
# 200: { "data": { "token": "<JWT>", "newAccount": false, "accountId": "acc_xxx" } }
# 201: 新註冊使用者(newAccount=true)curl -s -X POST https://auth.example.com/api/v1/login/password \
-H "Content-Type: application/json" \
-d '{
"countryCode": "<country-code>",
"mobile": "<mobile-number>",
"password": "<password>",
"clientId": "aile-web"
}'
# 響應:{ "data": { "token": "<JWT>", "tokenType": "Bearer", "expiresIn": 2592000, "accountId": "acc_xxx", "phone": "<mobile-number>" } }curl -s -X POST https://auth.example.com/api/v1/login/channel \
-H "Content-Type: application/json" \
-d '{
"thirdChannel": "WECHAT",
"scopeId": "openid_xxx",
"clientId": "aile-web"
}'
# 已繫結手機號:{ "data": { "needBindPhone": false, "token": "<JWT>", ... } }
# 未繫結手機號:{ "data": { "needBindPhone": true, "bindToken": "bt_xxx", "bindTokenExpiresIn": 600 } }BFF 拿到 AuthServer 簽發的 JWT 後,應將其透傳給下游資源服務:
// Feign 攔截器自動注入 satoken header
@Component
public class AuthTokenFeignInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
// 從當前請求上下文取得 token
String token = AuthContext.getCurrentToken();
if (token != null) {
template.header("satoken", token); // 與 AuthServer 設定一致
}
}
}// 請求上下文(ThreadLocal)
public class AuthContext {
private static final ThreadLocal<String> TOKEN_HOLDER = new ThreadLocal<>();
private static final ThreadLocal<String> ACCOUNT_ID_HOLDER = new ThreadLocal<>();
public static void setToken(String token) { TOKEN_HOLDER.set(token); }
public static String getCurrentToken() { return TOKEN_HOLDER.get(); }
public static void setAccountId(String id) { ACCOUNT_ID_HOLDER.set(id); }
public static String getCurrentAccountId() { return ACCOUNT_ID_HOLDER.get(); }
public static void clear() { TOKEN_HOLDER.remove(); ACCOUNT_ID_HOLDER.remove(); }
}// BFF Filter
@WebFilter(urlPatterns = "/api/*")
public class BffAuthFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) {
String token = request.getHeader("satoken");
if (token == null) {
response.setStatus(401);
return;
}
try {
// 可選:先驗證一次 JWT
TokenClaims claims = tokenService.parseAndValidate(token);
AuthContext.setToken(token);
AuthContext.setAccountId(claims.getSubject());
chain.doFilter(request, response);
} catch (Exception e) {
response.setStatus(401);
} finally {
AuthContext.clear();
}
}
}curl -s -X POST https://auth.example.com/api/v1/login/logout \
-H "satoken: <JWT>"
# 響應始終 200(冪等)登出後,AuthServer 將 JWT 加入黑名單。如果資源服務訂閱了 TokenInvalidated webhook 事件,也會收到相應通知。
BFF 在新使用者註冊前可查詢使用者是否已存在於生態中:
curl -s -X POST https://auth.example.com/account/scope/v1/real/check \
-H "Content-Type: application/json" \
-d '{
"channel": "WECHAT",
"scopeId": "openid_xxx"
}'
# 響應:{ "data": true } // true=已存在且繫結手機號, false=不存在或未繫結如果你的 BFF 是新客戶端,需要在 AuthServer 註冊:
# 透過 Admin API 註冊(需要 admin 許可權)
curl -s -X POST https://auth.example.com/admin/v1/oauth-clients \
-H "Content-Type: application/json" \
-H "satoken: <admin-token>" \
-d '{
"clientId": "my-bff-app",
"clientName": "My BFF Application",
"requireSecret": true,
"clientSecret": "<oauth-client-secret>",
"allowOtpLogin": true,
"allowPasswordLogin": true,
"allowChannelLogin": true,
"remark": "自建 BFF 客戶端"
}'客戶端設定項說明:
| 欄位 | 說明 |
|---|---|
clientId | 客戶端唯一標識 |
clientName | 顯示名稱 |
requireSecret | 是否要求 clientSecret(true=機密客戶端, false=公開客戶端) |
allowOtpLogin | 是否允許 OTP 登入 |
allowPasswordLogin | 是否允許密碼登入 |
allowChannelLogin | 是否允許渠道登入 |
enabled | 是否啟用 |
客戶端 ──HTTPS──▶ BFF ──內部網路 HTTP──▶ 資源服務
satoken satoken 本地驗證AuthServer 支援金鑰輪轉(透過 JWKS 回傳多把 key,用 kid 區分):
jwt.private-key / jwt.public-key 設定)。kid。kid,舊 JWT 在有效期內仍可用舊公鑰驗證。建議:資源服務的公鑰快取 TTL 設定為 1 小時以內,確保能及時感知金鑰輪轉。
JWT 的 iat 和 exp 基於 Unix 時間戳。所有服務(AuthServer + 資源服務 + BFF)必須透過 NTP 保持時鐘同步。
# bootstrap.yml / Nacos
server:
port: 8080
jwt:
issuer: https://auth.your-domain.com # 正式環境改為實際域名
expiration: 2592000 # 30 天
private-key-path: ${JWT_PRIVATE_KEY_PATH} # 私鑰由安全儲存掛載
public-key-path: ${JWT_PUBLIC_KEY_PATH} # 公鑰由部署環境掛載
sa-token:
token-name: satoken # HTTP Header 名稱
as:
resource-server:
registration-token: "<registration-token>" # 資源服務註冊所需令牌# 資源服務側
auth:
server:
jwks-uri: https://auth.example.com/oauth2/jwks
issuer: https://auth.your-domain.com
local-blacklist:
ttl: 2592000 # 黑名單 TTL,建議設為 token 有效期
max-size: 10000 # 最大快取條目
webhook:
secret: "${AUTH_WEBHOOK_SECRET}" # HMAC 簽名金鑰# BFF 側
auth:
server:
base-url: https://auth.example.com
client:
id: my-bff-app
secret: "${AUTH_CLIENT_SECRET}"
token:
header-name: satoken # 與 AuthServer 一致整合完成後,逐項驗證:
GET /oauth2/jwks 回傳正確的 RSA 公鑰(含 kid, n, e)。Q1:資源服務能直接存取 AuthServer 的資料庫/Redis 嗎?
不能,也不應該。資源服務應透過 JWKS 端點取得公鑰,透過 webhook 接收信任事件。AuthServer 的內部儲存是實現細節,不應暴露給其他服務。
Q2:Introspect 端點需要認證嗎?
GET /oauth2/introspect 端點本身被 SaTokenJwtAuthInterceptor 保護(見 WebMvcAuthConfig.java),需要一個有效的 satoken 頭。這適合使用者 Token 的自檢場景。對於服務間驗證,建議使用本地 JWT 驗證(模式 A)。
Q3:如果 Token 被盜用怎麼辦?
AuthServer 支援 Token 撤銷(revoke):POST /oauth2/revoke 將 Token 加入黑名單。資源服務透過 webhook 事件感知撤銷,拒絕已撤銷的 Token。
Q4:客戶端需要同時支援 Web 和 App,如何區分?
透過 deviceData header 中的 channel 欄位區分。Token 的 channel claim 會記錄簽發時的渠道資訊。
Q5:Token 過期了怎麼辦?
客戶端需要重新登入取得新 Token。BFF 可以攔截 401 響應,引導客戶端重新整理登入態。
文件版本: v1.0\ 最後更新: 2026-07-14\ 基於原始碼:
springcloud-aile/authserverrelease 分支