认证与安全

认证落在 httpx 的入站中间件槽上:handler 不解析 Authorization、 不手写 401。HTTP 侧的安全头默认下发,CORS 默认不发。两件事合起来就是这一页。

Verifier:两种验签模式,二选一

auth.New(Config) 由 Config 里配了哪个字段决定模式。 两个都配或都不配都会直接报错——不给出模棱两可的验签路径。

模式 配置字段 接受的算法 用途
HMAC(共享密钥) secret HS256 / HS384 / HS512 内部调用、测试
非对称(JWKS) jwks_url RS256/384/512、ES256/384/512 对接 OIDC,公钥从端点拉取

算法白名单是写死的(allowedMethods),配合 jwt.WithValidMethods。这就是经典的算法混淆攻击的解药: 一个用公钥当 HMAC 密钥签出来的 HS256 token,拿到非对称模式的验签器上会被直接拒绝。

auth:
  # 模式 A:共享密钥(内部调用、测试)
  # secret: ${AUTH_SECRET}
  # 模式 B:对接 OIDC,公钥从 JWKS 端点拉
  jwks_url: https://idp.example.com/.well-known/jwks.json

  issuer: https://idp.example.com   # 可选:校验 iss
  audience: orders-api              # 可选:校验 aud
  header: Authorization             # 默认 Authorization
  scheme: Bearer                    # 默认 Bearer
  leeway: 30s                       # 容忍时钟偏移,默认 30s
  jwks_ttl: 10m                     # JWKS 缓存时长,默认 10m

留空或删掉 auth: 段即禁用,env.Auth 为 nil。 verticle 在配了 secret 或 jwks_url 时构建 env.Auth;服务在 BuildRouter 里用它,就是这一层唯一的开关。

jwt.WithExpirationRequired() 也总是打开:没有 exp 的 token 不是"永不过期",而是无效。

分级保护:靠中间件顺序表达

Use 只护它之后注册的路由,所以"公开 → 登录态 → 更高权限"就是三行代码, 不需要任何权限 DSL。

flowchart TD
    REQ([请求]) --> M{{mux 路由匹配}}
    M -->|"路径是 /api/v1/login"| L["login handler
(公开,签发 token)"] M -->|"其他路径"| AUTH["verifier.Middleware()
验签 → Principal 入 ctx"] AUTH -->|"缺 token / 签名错 / 过期"| E401["401 code 40101"] AUTH -->|"通过"| R1["/api/v1/profile"] AUTH --> G["RequireRole admin
(要求 admin 角色)"] G -->|"角色不足"| E403["403 code 40301"] G -->|"通过"| R2["/api/v1/admin/audit"] classDef deny fill:#fdf6f0,stroke:#c8641f,color:#2b2722 class E401,E403 deny
层级就是书写顺序:srv.Handle 公开路由 → srv.Use(verifier.Middleware()) → srv.Use(auth.RequireRole("admin"))。 公开路由必须先注册——Use 不回头保护已经注册的路由。
func (s *authDemo) BuildRouter(srv *httpx.Server) error {
    srv.Handle("POST", "/api/v1/login", s.login)   // 公开

    srv.Use(s.verifier.Middleware())               // 这一行往下都要 token
    srv.Handle("GET", "/api/v1/profile", profile)

    srv.Use(auth.RequireRole("admin"))             // 再加一层:token + admin
    srv.Handle("GET", "/api/v1/admin/audit", audit)
    return nil
}

三个开箱守卫,都是 httpx.Middleware:

守卫 要求
RequireScope(scopes...)principal 必须携带每一个列出的 scope
RequireRole(roles...)必须携带每一个列出的角色
RequireAnyRole(roles...)任意一个即可

守卫是失败关闭的:没有认证中间件在它前面,mustPrincipal 返回 401 而不是放行。漏挂一行不会变成公开接口。

更细的资源级授权在 handler 里自己判,用 p.HasScope / p.HasRole / p.HasAnyRole,拒绝时返回 errs.Forbidden(40301, …)。

Principal:handler 读到的调用方

// profile 读中间件放进 ctx 的主体
func profile(ctx context.Context, _ *http.Request) (any, error) {
    p, _ := auth.FromContext(ctx)
    return map[string]any{"subject": p.Subject, "scopes": p.Scopes, "roles": p.Roles}, nil
}

Principal 暴露 Subject、Issuer、 Scopes、Roles 与原始 Claims。 scope 与 role 的 claim 有好几种方言,principalFrom 都接: scope 认 scope(OAuth2 的空格分隔字符串)、scp、 scopes;role 认 roles、role、 groups。取第一个非空的。

Optional() 是第三个中间件:有 token 就解析、没有就放行, 适合"公开页面登录后个性化"。但畸形的 token 仍然拒绝——静默忽略坏凭证只会藏 bug。

签名与密钥解析的失败语义刻意分开:

情况 响应
缺 token / 格式错 / 签名错 / 过期401 {"code":40101}
有 token 但 scope / role 不够403 {"code":40301}
JWKS 端点不可达503 {"code":50301}

最后一行是刻意的:身份提供方短暂不可用,不该让客户端以为自己的 token 坏了。 而且 JWKS 拉取失败时会继续用上一次的公钥(可用性优先),只有从未成功拉取过 才对外报 503。缓存里出现没见过的 kid 时会立即重取一次文档, 密钥轮转不用等 jwks_ttl 过期;并发请求靠刷新计数器串行化, 不会把 IdP 打穿。

Issue 让服务在 HMAC 模式下自己签发 token(内部调用、测试不必 再 import JWT 库)。非对称模式下签发方是 IdP,这个方法会报错。 IssueRequest.Extra 不能覆盖 sub / iat / exp / iss / aud——否则等于允许调用方伪造身份与有效期。

默认安全响应头

httpx.New 在链首装好 SecureHeaders,不用你写。 它在任何 UseOuter 之前,所以 /healthz、 /readyz、/metrics、HandleRaw 路由, 以及 guard 渲染的错误响应,全都被覆盖。

X-Content-Type-Options: nosniff                              # 不猜 MIME
X-Frame-Options: DENY                                         # 防点击劫持
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'
Referrer-Policy: no-referrer
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Strict-Transport-Security: max-age=31536000; includeSubDomains   # 仅 https

CSP 取的是最严的一档,因为 API 只回 JSON:什么都不允许加载、不允许执行。 这套头每请求成本为零,默认开而不是默认关的理由是: 多一个头对某条路由不合适,可以单独放宽;少一个头则要到审计时才被发现。

HSTS 只在 https 上发 判定依据是 r.TLS != nil 或 X-Forwarded-Proto: https (TLS 终止在网关时)。明文 http 上发 HSTS 至少会被忽略;若浏览器此前在共享的 localhost 端口上收到过,还会被钉死在它并不提供的 https 上。

CORS:默认不发,白名单显式给

同源接口不该邀请外部页面来读。CORSPolicy() 空参(也就是默认) 不写任何 CORS 头,也没有一个"默认允许"的宽松档位。

srv.UseOuter(httpx.CORSPolicy("https://app.example"))  // 精确来源;OPTIONS 预检自动应答
// srv.UseOuter(httpx.CORSPolicy("*"))                 // 任意来源,且不带 credentials
参数 行为
空不写任何 CORS 头(默认)
精确来源列表命中则回该来源 + Access-Control-Allow-Credentials: true
"*"回 *,不带 credentials——两者在规范里不能共存

被拒绝时仍然输出 Vary: Origin,并且显式删掉 Access-Control-Allow-Credentials。前者是缓存正确性的前提: 没有它,缓存可能拿 A 来源的响应用来回答 B 来源的请求。

CORSPolicy 是 OuterMiddleware,注册在 SecureHeaders 之后,所以白名单之外丢掉的只是 CORS 头,安全头照发。 预检(带 Access-Control-Request-Method 的 OPTIONS)由它直接回 204。

约定

相关示例