认证与拦截器
生成的客户端自己不做认证。每个请求都经过创建它们时所用的 Fetcher,请求除了路由和请求体之外携带的任何东西,无论是令牌还是你自己的请求头,都由这个 Fetcher 的拦截器加上。每个 Fetcher 配置一次,基于它的每个客户端都会继承。
服务根据这些凭据决定调用方是谁:它的租户、所有者、空间以及它能做什么(身份)。路由只指明聚合,从不指明它的租户或所有者,所以关于调用方的一切都不是客户端方法的参数。
CoSec
CoSec 是 Wow 服务用来认证的授权框架(保护部署)。@ahoo-wang/fetcher-cosec 为它配置 Fetcher:
pnpm add @ahoo-wang/fetcher-cosec @ahoo-wang/fetcher-eventbus @ahoo-wang/fetcher-storageimport { Fetcher } from '@ahoo-wang/fetcher';
import { CoSecConfigurer, CoSecTokenRefresher } from '@ahoo-wang/fetcher-cosec';
export const service = new Fetcher({ baseURL: 'https://api.example.com' });
export const cosec = new CoSecConfigurer({
appId: 'order-console',
// Refresh through a Fetcher without the CoSec interceptors.
tokenRefresher: new CoSecTokenRefresher({
fetcher: new Fetcher({ baseURL: 'https://api.example.com' }),
endpoint: '/auth/refresh',
}),
onUnauthorized: () => window.location.assign('/login'),
onForbidden: async () => console.warn('Access denied'),
});
cosec.applyTo(service);登录之后保存令牌对;从此这个 Fetcher 的每个请求都会携带它:
import type { CoSecConfigurer } from '@ahoo-wang/fetcher-cosec';
export function onSignedIn(
cosec: CoSecConfigurer,
token: { accessToken: string; refreshToken: string },
) {
cosec.tokenStorage.signIn(token);
}applyTo 加上 Authorization: Bearer <access token>,在令牌过期之前刷新它(并发的请求只刷新一次),在刷新之后重试被以 401 回答的请求,在刷新也解决不了的 401 时调用 onUnauthorized,在 403 时调用 onForbidden。它还发送 CoSec-App-Id、一个 CoSec-Device-Id 和一个 CoSec-Request-Id。只有当 CoSec 通过该应用的角色授权了请求时,服务才把这个应用记录为提交的 app_id:这个请求头本身不被信任。
服务拒绝的请求以 WowProblemError reject:401 wow.auth.unauthenticated、403 wow.auth.forbidden,或者对调用方作用域之外的聚合的 403 wow.aggregate.forbidden。
空间与作用域
属于多个空间的调用方,用 space(Wow-Space)为每个请求选择一个空间;不选时,它在自己唯一的空间中行事,或者不在任何空间中。被允许跨作用域行事的调用方,用 scope(Wow-Scope)指明另一个租户、所有者或空间。两者都是每个客户端方法的请求选项:
import { filter, listQuery } from '@ahoo-wang/wow-client/dsl';
const query = listQuery({ filter: filter.eq('state.status', 'PAID'), limit: 20 });
const inStore = await orderQueries.list(query, { space: 'store-42' });
const forTenant = await orderQueries.list(query, { scope: { tenantId: 'tenant-b' } });不能在它所指明的作用域中行事的调用方,会以 403 被拒绝。
你自己的拦截器
请求拦截器在其 Fetcher 的每个请求发送之前看到它。eShop 的 Web 界面不用令牌,而是通过一个拦截器,用它的开发服务器所信任的请求头为请求签名:
/** Sends the caller's headers on every request, as a host's CoSec interceptor sends its token. */
class CallerHeaders implements RequestInterceptor {
readonly name = "EshopCaller";
readonly order = 0;
constructor(private readonly user: User) {}
intercept(exchange: FetchExchange): void {
Object.assign(exchange.ensureRequestHeaders(), callerHeaders(this.user));
}
}然后用 fetcher.interceptors.request.use(new CallerHeaders(user)) 注册它。这些请求头对生产服务没有意义:在那里,只有已认证的主体才是调用方。响应拦截器和错误拦截器的用法相同(Fetcher 拦截器)。错误拦截器看不到 WowProblemError;客户端在拦截器运行之后,才把失败的响应变成它。
多个服务
给每个服务一个自己的 Fetcher,并对每个都应用 CoSec。应用到多个 Fetcher 上的同一个 CoSecConfigurer 保持同一个令牌存储,所以用户只需登录一次:
import { Fetcher } from '@ahoo-wang/fetcher';
import type { CoSecConfigurer } from '@ahoo-wang/fetcher-cosec';
export function services(cosec: CoSecConfigurer) {
const ordering = new Fetcher({ baseURL: 'https://ordering.example.com' });
const catalog = new Fetcher({ baseURL: 'https://catalog.example.com' });
cosec.applyTo(ordering);
cosec.applyTo(catalog);
return { ordering, catalog };
}在按上下文路由的网关之后,一个指向网关的 Fetcher 就能服务所有服务:每个路由都以它的上下文开头。