CORS 跨域
当浏览器里的单页应用(SPA)直接调用 wicket 的发现文档、JWKS、令牌或 UserInfo 端点时,需要跨域支持。本页说明 wicket v0.1.4 在这件事上做了什么、没做什么,并给出一个已验证的宿主中间件写法。
结论先行
- wicket 不写任何
Access-Control-*响应头,也不处理OPTIONS预检请求。代码中没有任何地方输出这些头。 - wicket 提供了判定材料:选项中的跨域路径列表
opts.Cors.CorsPaths,以及一个按客户端AllowedCorsOrigins判断来源是否放行的服务services.CorsPolicyService。但 wicket 内部没有任何组件调用它们。 - 因此,跨域需要宿主在中间件链最外层自己处理,可以直接复用上面两项判定材料。
wicket 里与 CORS 有关的东西
CorsOptions
WicketOptions.Cors 有三个字段:
| 字段 | 默认值 | v0.1.4 中的实际作用 |
|---|---|---|
CorsPaths | 见下 | 只是一份路径清单,wicket 内部不读取 |
CorsPolicyName | "wicket" | 仅在启动时检查非空,为空时 srv.UseWicket() 直接 panic;除此之外不参与任何逻辑 |
PreflightCacheDuration | nil | 没有任何代码读取它 |
CorsPaths 的默认值(实测打印 opts.Cors.CorsPaths):
/.well-known/openid-configuration
/.well-known/openid-configuration/jwks
/connect/token
/connect/userinfo
/connect/revocation其中 /connect/revocation 在 v0.1.4 中只有路径常量,没有注册令牌撤销端点,发往它的请求会落到宿主自己的 handler。
CorsPolicyService 与 Client.AllowedCorsOrigins
// services 包
type CorsPolicyService interface {
IsOriginAllowed(string) bool
}容器中注册的实现会遍历当前所有客户端的 AllowedCorsOrigins,只要任一客户端列出了该来源就返回 true。需要注意:
- 比较是逐字符精确匹配:大小写、端口、末尾斜杠都必须与浏览器发送的
Origin头一致,例如写https://app.example.com,不要写https://app.example.com/。 - 不支持通配符。
- 判定是“全局”的:只要有一个客户端允许某个来源,这个来源就对所有 CORS 路径放行,与发起请求的是哪个客户端无关(预检请求本来也不携带客户端身份)。
- 该服务按 Transient 方式注册,在解析时读取当前客户端列表并保存一份快照。如果你用
cfg.SetClientsProvider提供会变化的客户端列表,请在每次请求时解析它,而不是在启动时解析一次后复用。
wicket 收到 OPTIONS 时会发生什么
wicket 的端点路由只按路径匹配、不看方法,所以预检请求如果到达 wicket,会被当作普通协议请求处理。实测:
| 预检请求 | wicket 的响应 |
|---|---|
OPTIONS /connect/token | 400,响应体为 invalid_request |
OPTIONS /.well-known/openid-configuration | 405 Method Not Allowed |
OPTIONS /connect/userinfo | 405 Method Not Allowed |
两种响应都不带 CORS 头,浏览器会判定预检失败。所以预检请求必须在到达 wicket 之前由宿主回应。
宿主中间件写法
下面的中间件只处理 opts.Cors.CorsPaths 中的路径,用 wicket 的 CorsPolicyService 判定来源,自己回应预检请求。它已在快速开始示例的基础上编译并运行验证。
import (
"net/http"
"strings"
"github.com/gonewx/godi"
"github.com/gonewx/wicket/services"
)
// withCORS 只处理 wicket 的跨域路径;其余请求原样放行。
func withCORS(p godi.Provider, corsPaths []string, next http.Handler) http.Handler {
paths := make(map[string]bool, len(corsPaths))
for _, path := range corsPaths {
paths[strings.ToLower(path)] = true
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin == "" || !paths[strings.ToLower(r.URL.Path)] {
next.ServeHTTP(w, r)
return
}
w.Header().Add("Vary", "Origin")
// 每次请求解析:策略服务在解析时读取当前客户端列表
allowed := godi.GetRequired[services.CorsPolicyService](p).IsOriginAllowed(origin)
// 预检请求:在这里结束,不交给 wicket(wicket 不处理 OPTIONS)
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
if allowed {
h := w.Header()
h.Set("Access-Control-Allow-Origin", origin)
h.Set("Access-Control-Allow-Methods", "GET, POST")
h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
h.Set("Access-Control-Max-Age", "600")
}
w.WriteHeader(http.StatusNoContent)
return
}
if allowed {
w.Header().Set("Access-Control-Allow-Origin", origin)
}
next.ServeHTTP(w, r)
})
}装配时放在 hosting.ScopeMiddleware 之外,这样预检请求不会进入 wicket:
opts := godi.GetRequired[*options.WicketOptions](root)
// ... 装配客户端(含 AllowedCorsOrigins)、cfg.Freeze()
srv, err := server.DefaultServer(root, server.WithLogger(logger))
if err != nil {
log.Fatal(err)
}
wicketHandler := hosting.ScopeMiddleware(root)(srv.UseWicket()(app))
handler := withCORS(root, opts.Cors.CorsPaths, wicketHandler)客户端侧声明允许的来源:
client.AllowedCorsOrigins = []string{"https://app.example.com"}实测结果(来源 https://app.example.com 已在客户端中声明,https://evil.example.com 未声明):
| 请求 | 结果 |
|---|---|
预检 OPTIONS /connect/token,已声明来源 | 204,带 Access-Control-Allow-Origin: https://app.example.com 及方法、头、Max-Age |
预检 OPTIONS /connect/token,未声明来源 | 204,不带任何 Access-Control-Allow-* 头,浏览器拒绝后续请求 |
GET 发现文档,已声明来源 | 200,带 Access-Control-Allow-Origin |
GET 发现文档,未声明来源 | 200,不带 Access-Control-Allow-Origin,浏览器不让脚本读取响应 |
POST /connect/token,已声明来源 | 200,令牌响应带 Access-Control-Allow-Origin |
几点设计说明:
- 不设置
Access-Control-Allow-Credentials。 这些端点用请求参数或Authorization头传递凭据,不依赖 Cookie,不需要放开带 Cookie 的跨域请求。 Vary: Origin必须带上。 响应内容随Origin不同而不同,缺少它时,中间缓存可能把一个来源的 CORS 头返回给另一个来源。Max-Age自行决定。opts.Cors.PreflightCacheDuration不会被 wicket 使用;如果你想集中配置,可以在中间件里读取它并换算成秒。- 只放行需要的路径。 授权端点
/connect/authorize和结束会话端点是浏览器整页跳转,不需要 CORS;内省端点由后端 API 调用,也不应向浏览器开放。如需额外路径,追加到传给中间件的列表即可,不必修改opts.Cors.CorsPaths。
浏览器里的客户端
在浏览器中运行的 SPA 无法保管客户端密钥。让 SPA 直接调用令牌端点时,应使用授权码 + PKCE,并把该客户端配置为不要求密钥(公开客户端)。不要为了通过 CORS 而把机器客户端的密钥放进前端代码。
不想复用 wicket 的判定时
如果你的来源白名单与客户端配置无关(例如由网关统一管理),中间件里把 CorsPolicyService 换成自己的集合判断即可,其余逻辑不变。也可以使用任何成熟的第三方 CORS 中间件,只要满足两点:覆盖上面列出的 wicket 路径,并且包在 wicket 中间件之外,让预检请求在到达 wicket 前结束。