认证与安全
认证落在 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:什么都不允许加载、不允许执行。 这套头每请求成本为零,默认开而不是默认关的理由是: 多一个头对某条路由不合适,可以单独放宽;少一个头则要到审计时才被发现。
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。
约定
- 密钥不落文件:生产用
secret: ${AUTH_SECRET}从环境变量注入。 - 登录失败一条消息:"用户不存在"和"密码错误"返回同一句话,否则就是给攻击者一个用户名枚举的接口。
- 开关不等于权限:运行期翻转功能开关的接口(见开关与审计)本身必须过认证与授权。
- 本包依赖
golang-jwt/jwt/v5(零传递依赖)。验签是安全敏感逻辑,不自研。
相关示例
- examples/auth:HMAC 验签、三级路由保护、
Issue签发。 - HTTP 与路由:中间件槽、封套、安全响应头的挂载位置。
- 开关与审计:功能开关与审计轨迹。