单页应用(SPA)的认证流程一直是个棘手的问题。常见的做法是将认证逻辑,特别是令牌(Token)的刷新机制,散布在应用的各个角落,比如混杂在API请求的封装层或者全局的HTTP拦截器中。这种方式不仅污染了业务代码,而且处理并发请求下的令牌刷新时,极易出现竞态条件,导致多次无效的刷新请求。令牌的存储方案也同样令人头疼,localStorage
存在XSS风险,而 HttpOnly
Cookie在跨域场景下又面临诸多限制。
我们的目标是构建一个将认证逻辑与应用代码完全解耦的架构。应用本身应当只负责发起业务请求,例如 fetch('/api/v1/profile')
,而不必关心 Authorization
头是否存在、令牌是否过期、以及何时去刷新它。这一切都应该在“后台”无感知地发生。
这个构想需要两个代理层面的协作:一个在服务端边缘,一个在浏览器客户端。服务端边缘,我们选择Envoy Proxy,利用其强大的ext_authz
过滤器作为认证守门人,确保任何到达上游服务的请求都经过了严格的身份验证。而在浏览器端,Service Worker就是我们的不二之选,它能扮演一个客户端“边车代理”的角色,拦截应用发出的所有fetch
请求,在幕后完成令牌的附加、刷新和重试。
架构蓝图:双代理协同
整个流程的核心思想是责任分离。Envoy负责“验证”,Service Worker负责“准备”。
sequenceDiagram participant SPA as 单页应用 (主线程) participant SW as Service Worker (浏览器) participant Envoy as Envoy Proxy (边缘) participant AuthZ as 外部授权服务 participant Upstream as 上游业务服务 participant IdP as Identity Provider (OIDC) Note over SPA: 用户操作触发API请求 SPA->>SW: fetch('/api/v1/profile') par 令牌处理 SW->>SW: 1. 从IndexedDB读取令牌 alt 令牌有效 SW->>Envoy: 2. 附加 Authorization header 并转发请求 else 令牌过期或不存在 SW->>IdP: 3. 使用 refresh_token 请求新令牌 IdP-->>SW: 4. 返回新的 access_token/refresh_token SW->>SW: 5. 更新IndexedDB中的令牌 SW->>Envoy: 6. 附加新令牌并转发原始请求 end end Envoy->>AuthZ: 7. Check(request_with_token) AuthZ->>AuthZ: 8. 验证JWT签名、有效期等 AuthZ-->>Envoy: 9. gRPC Response: OK Envoy->>Upstream: 10. 转发原始请求 Upstream-->>Envoy: 11. 业务数据响应 Envoy-->>SW: 12. 返回业务数据 SW-->>SPA: 13. resolve(response)
在这个模型中:
- SPA:完全不感知认证逻辑。它只管通过
fetch
调用API。 - Service Worker:作为客户端代理,负责令牌的存储(我们选用IndexedDB以隔离主线程的直接访问)、附加和刷新。这是实现“无感知”的关键。
- Envoy Proxy:作为服务端网关,通过
ext_authz
过滤器将认证决策委托给一个专门的微服务。它不信任任何客户端传来的声明,只信任AuthZ
服务的裁决。 - **外部授权服务 (AuthZ)**:一个简单的gRPC服务,负责接收Envoy的请求,验证JWT的合法性。这是零信任原则的体现。
服务端壁垒:配置Envoy与外部授权服务
首先搭建后端防线。Envoy的配置是第一步。我们不使用内置的jwt_authn
过滤器,因为它虽然方便,但在需要更复杂逻辑(如查询用户状态、检查IP黑名单)时灵活性不足。ext_authz
过滤器将决策权完全交出,是更具扩展性的选择。
Envoy 配置文件 (envoy.yaml
)
这份配置的核心是envoy.filters.http.ext_authz
。
# envoy.yaml
static_resources:
listeners:
- name: listener_0
address:
socket_address: { address: 0.0.0.0, port_value: 10000 }
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: ingress_http
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match: { prefix: "/" }
route: { cluster: upstream_service }
http_filters:
# 关键部分:外部授权过滤器
- name: envoy.filters.http.ext_authz
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
# 使用gRPC服务进行通信
grpc_service:
envoy_grpc:
cluster_name: ext_authz_service
# 设定超时,避免授权服务拖垮整个链路
timeout: 0.5s
# 如果授权服务返回失败,不直接拒绝,而是允许我们自定义响应
failure_mode_allow: false
# 默认拒绝,除非AuthZ服务明确允许
status_on_error:
code: 503
# 为了让AuthZ服务能拿到JWT,需要把Authorization头传过去
with_request_body:
max_request_bytes: 0 # 我们不需要body
allow_partial_message: false
allowed_headers:
patterns:
- exact: "authorization"
- name: envoy.filters.http.router
typed_config: {}
clusters:
- name: upstream_service
connect_timeout: 0.25s
type: STRICT_DNS
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: upstream_service
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address: { address: host.docker.internal, port_value: 8080 }
- name: ext_authz_service
connect_timeout: 0.25s
type: STRICT_DNS
lb_policy: ROUND_ROBIN
# 必须是HTTP/2,因为我们使用gRPC
typed_extension_protocol_options:
envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
"@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
explicit_http_config:
http2_protocol_options: {}
load_assignment:
cluster_name: ext_authz_service
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address: { address: host.docker.internal, port_value: 9001 }
外部授权服务实现 (Golang)
这个gRPC服务逻辑非常纯粹:解析Authorization
头,验证JWT,然后返回结果。在生产项目中,这里会使用一个健壮的JWT库,并从一个可靠的源(如OIDC的JWKS端点)获取公钥。
// main.go
package main
import (
"context"
"log"
"net"
"strings"
"github.com/golang-jwt/jwt/v4"
auth "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3"
core "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
"google.golang.org/genproto/googleapis/rpc/status"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
)
// OIDC的JWKS公钥,生产环境中应动态获取并缓存
const jwksPublicKey = `-----BEGIN PUBLIC KEY-----
... YOUR OIDC PROVIDER's PUBLIC KEY ...
-----END PUBLIC KEY-----`
type server struct{}
// Check实现了AuthorizationServer接口
func (s *server) Check(ctx context.Context, req *auth.CheckRequest) (*auth.CheckResponse, error) {
authHeader, ok := req.Attributes.Request.Http.Headers["authorization"]
if !ok {
log.Println("Missing Authorization Header")
return &auth.CheckResponse{
Status: &status.Status{
Code: int32(codes.Unauthenticated),
Message: "Authorization header is required",
},
}, nil
}
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
log.Println("Invalid Authorization Header format")
return &auth.CheckResponse{
Status: &status.Status{
Code: int32(codes.Unauthenticated),
Message: "Invalid token format",
},
}, nil
}
tokenString := parts[1]
// 使用公钥解析和验证JWT
key, err := jwt.ParseRSAPublicKeyFromPEM([]byte(jwksPublicKey))
if err != nil {
log.Printf("Failed to parse public key: %v", err)
return &auth.CheckResponse{
Status: &status.Status{Code: int32(codes.Internal)},
}, nil
}
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, jwt.ErrSignatureInvalid
}
return key, nil
})
// 这里的错误处理非常重要。令牌过期是一种特定的、可预期的失败。
if err != nil {
log.Printf("Token validation failed: %v", err)
// 明确区分令牌过期和其他错误
if ve, ok := err.(*jwt.ValidationError); ok {
if ve.Errors&jwt.ValidationErrorExpired != 0 {
return &auth.CheckResponse{
Status: &status.Status{
Code: int32(codes.Unauthenticated),
Message: "Token is expired",
},
}, nil
}
}
return &auth.CheckResponse{
Status: &status.Status{
Code: int32(codes.Unauthenticated),
Message: "Invalid token",
},
}, nil
}
if !token.Valid {
log.Println("Token is invalid")
return &auth.CheckResponse{
Status: &status.Status{
Code: int32(codes.Unauthenticated),
Message: "Invalid token",
},
}, nil
}
log.Println("Token validation successful")
// 验证成功,返回OK响应
return &auth.CheckResponse{
Status: &status.Status{
Code: int32(codes.OK),
},
HttpResponse: &auth.CheckResponse_OkResponse{
OkResponse: &auth.OkHttpResponse{
// 可以选择在这里添加或覆盖一些请求头,比如解析出的用户信息
Headers: []*core.HeaderValueOption{
{
Header: &core.HeaderValue{
Key: "x-user-id",
Value: token.Claims.(jwt.MapClaims)["sub"].(string),
},
},
},
},
},
}, nil
}
func main() {
lis, err := net.Listen("tcp", ":9001")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
s := grpc.NewServer()
auth.RegisterAuthorizationServer(s, &server{})
log.Println("ext_authz gRPC server listening on :9001")
if err := s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
至此,服务端的防线已经建立。任何没有有效JWT的请求都会被Envoy在入口处直接拒绝,返回401或403,根本无法触及后端的业务服务。
客户端代理:Service Worker的实现细节
现在是时候实现客户端的“魔法”了。Service Worker的核心是fetch
事件监听器。
令牌存储:为什么是IndexedDB
我们不使用 localStorage
或 sessionStorage
。主线程中的任何脚本都可以访问它们,一旦发生XSS攻击,存储的令牌就会被轻易窃取。Service Worker运行在一个独立的全局上下文中,无法直接访问DOM,但它可以访问IndexedDB。将令牌存储在IndexedDB中,意味着只有Service Worker自身可以读写它们,主线程需要通过postMessage
与Service Worker通信来间接操作,这提供了一层额外的安全隔离。
// token-store.js
// 一个简单的IndexedDB包装器,提供Promise API
const DB_NAME = 'auth_tokens_db';
const STORE_NAME = 'tokens';
const DB_VERSION = 1;
let dbPromise = null;
function getDb() {
if (!dbPromise) {
dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject("Error opening DB");
request.onsuccess = event => resolve(event.target.result);
request.onupgradeneeded = event => {
const db = event.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
};
});
}
return dbPromise;
}
export async function setTokens(tokens) {
const db = await getDb();
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
// 我们只存储一条记录,id固定
const record = { id: 'user_tokens', ...tokens };
const request = store.put(record);
request.onsuccess = () => resolve();
request.onerror = () => reject("Error saving tokens");
});
}
export async function getTokens() {
const db = await getDb();
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readonly');
const store = transaction.objectStore(STORE_NAME);
const request = store.get('user_tokens');
request.onsuccess = event => resolve(event.target.result);
request.onerror = () => reject("Error getting tokens");
});
}
export async function clearTokens() {
const db = await getDb();
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const request = store.delete('user_tokens');
request.onsuccess = () => resolve();
request.onerror = () => reject("Error clearing tokens");
});
}
核心拦截与刷新逻辑 (sw.js
)
这是整个架构中最精妙的部分。我们需要处理并发请求、令牌刷新和请求重试。一个常见的坑是:当多个API请求同时因为令牌过期而失败时,它们可能会触发多次并行的令牌刷新请求。我们必须确保在任何时刻,只有一个刷新流程在进行。
// sw.js
import { getTokens, setTokens, clearTokens } from './token-store.js';
const API_PREFIX = '/api/';
const OIDC_TOKEN_ENDPOINT = 'https://your-idp.com/oauth2/token';
const OIDC_CLIENT_ID = 'your_client_id';
// 刷新锁:确保同一时间只有一个刷新请求在进行
let tokenRefreshPromise = null;
// 主拦截逻辑
self.addEventListener('fetch', (event) => {
const { request } = event;
// 只拦截我们自己的API请求
if (request.url.includes(API_PREFIX)) {
event.respondWith(handleApiRequest(request));
}
});
async function handleApiRequest(request) {
// 1. 获取令牌
let tokens = await getTokens();
// 如果没有令牌,直接转发请求,让Envoy去拒绝它
if (!tokens || !tokens.access_token) {
return fetch(request);
}
// 2. 附加令牌并发送请求
const requestWithToken = attachToken(request, tokens.access_token);
const response = await fetch(requestWithToken);
// 3. 处理响应
// 401表示令牌可能过期,这是我们需要处理的关键情况
if (response.status === 401) {
console.log('SW: Received 401, attempting token refresh...');
try {
// 关键:调用带锁的刷新函数
const newAccessToken = await getRefreshedToken(tokens.refresh_token);
// 刷新成功后,使用新令牌重试原始请求
console.log('SW: Token refreshed successfully. Retrying original request.');
const newRequestWithToken = attachToken(request, newAccessToken);
return fetch(newRequestWithToken);
} catch (error) {
console.error('SW: Token refresh failed.', error);
// 刷新失败,可能是refresh_token也失效了。
// 清理掉所有令牌,并通知客户端需要重新登录。
await clearTokens();
notifyClientsToLogout();
// 返回原始的401响应
return response;
}
}
// 对于非401的响应,直接返回
return response;
}
function attachToken(request, accessToken) {
// 克隆请求对象,因为Request对象是不可变的
const headers = new Headers(request.headers);
headers.set('Authorization', `Bearer ${accessToken}`);
return new Request(request, { headers });
}
// 带锁的令牌刷新函数
async function getRefreshedToken(refreshToken) {
// 如果已经有正在进行的刷新请求,则等待它的结果
if (tokenRefreshPromise) {
console.log('SW: A token refresh is already in progress, waiting...');
return tokenRefreshPromise;
}
// 创建一个新的刷新请求Promise,并将其存起来
tokenRefreshPromise = new Promise(async (resolve, reject) => {
try {
const response = await fetch(OIDC_TOKEN_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
'grant_type': 'refresh_token',
'refresh_token': refreshToken,
'client_id': OIDC_CLIENT_ID,
}),
});
if (!response.ok) {
throw new Error(`Token refresh failed with status: ${response.status}`);
}
const newTokens = await response.json();
// 存储新令牌。一个好的实践是同时存储expires_at时间戳。
await setTokens({
access_token: newTokens.access_token,
refresh_token: newTokens.refresh_token || refreshToken, // 有些IdP不会在刷新时返回新的refresh_token
expires_in: newTokens.expires_in,
});
resolve(newTokens.access_token);
} catch (error) {
reject(error);
} finally {
// 无论成功或失败,完成后都要清空锁,以便下次可以发起新的刷新
tokenRefreshPromise = null;
}
});
return tokenRefreshPromise;
}
// 通知所有客户端(页面)需要登出
async function notifyClientsToLogout() {
const clients = await self.clients.matchAll({ type: 'window' });
clients.forEach(client => {
client.postMessage({ type: 'LOGOUT_REQUIRED' });
});
}
// 确保Service Worker在更新后能立即接管页面
self.addEventListener('install', () => {
self.skipWaiting();
});
self.addEventListener('activate', event => {
event.waitUntil(self.clients.claim());
});
在主应用中,我们只需要注册Service Worker,并监听它发来的消息即可。
// main.js
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(registration => console.log('ServiceWorker registration successful'))
.catch(err => console.error('ServiceWorker registration failed: ', err));
});
navigator.serviceWorker.addEventListener('message', event => {
if (event.data && event.data.type === 'LOGOUT_REQUIRED') {
// Service Worker通知我们需要登出
console.log('Logout required, redirecting to login page...');
// 在这里执行清理本地状态和重定向到登录页的操作
window.location.href = '/login';
}
});
}
// 现在,应用代码可以非常干净
async function fetchUserProfile() {
try {
const response = await fetch('/api/v1/profile');
if (!response.ok) {
throw new Error('Failed to fetch profile');
}
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error);
}
}
方案的局限性与展望
这个架构虽然优雅,但并非万能。首先,OIDC的初始授权码流程(用户重定向到IdP登录,然后带着授权码返回)仍然需要由主应用来处理。Service Worker无法发起顶层导航,所以它不能启动登录流程,只能在已获得refresh_token
后接管会话管理。
其次,Service Worker的生命周期管理有其复杂性。新版本的sw.js
部署后,需要正确处理install
, activate
和claim
事件,以确保平滑地接管控制权,否则可能导致新旧逻辑并存的混乱状态。
最后,这种模式主要服务于API请求。对于页面级的访问控制,比如某些路由需要登录才能访问,还是需要前端路由守卫等传统机制来配合。Service Worker解决的是数据通道的安全,而非视图层的访问控制。
未来的一个优化方向是探索将部分令牌验证逻辑,例如解析JWT的payload来做一些轻量级检查,下沉到Service Worker中。这可以避免一些明显无效的请求(如令牌在客户端已知已过期)被发送到网络,进一步优化性能。但这种做法也引入了客户端逻辑与服务端逻辑重复的风险,需要谨慎权衡。