auth — 认证与授权
pkg/core/auth 把 Bearer token 验签落到 httpx 的中间件槽上:
handler 不解析 Authorization 头,也不手写 401——中间件验完把
Principal 放进 ctx,handler 直接读。这个示例用三条路由演示
「公开 → 登录态 → 更高权限」的阶梯是如何用书写顺序表达的。
运行
go run ./examples/auth
TOKEN=$(curl -s -X POST localhost:8081/api/v1/login \
-d '{"username":"bob","password":"bob-pw"}' | sed 's/.*"token":"\([^"]*\)".*/\1/')
curl -i localhost:8081/api/v1/profile # 401,无 token
curl -i localhost:8081/api/v1/profile -H "Authorization: Bearer $TOKEN" # 200
curl -i localhost:8081/api/v1/admin/audit -H "Authorization: Bearer $TOKEN" # 200,bob 是 admin
# 换 alice 的 token 调 /admin/audit → 403,她没有 admin 角色
没有外部依赖,不需要 make dev。监听 :8081,
-conf 默认指向 examples/auth/config.yaml;
那份配置必须包含 auth 段,否则 Setup
会以 authdemo: the `auth` section must be configured 失败退出。
它演示了什么
- 两种验签模式:
auth.secret(HS256/384/512)或auth.jwks_url(RS/ES 系列),配置二选一。 - 分级保护:靠
Use只护其后注册的路由这一条规则表达层级。 - 三个开箱守卫:
RequireScope、RequireRole、RequireAnyRole。 - Principal 进 ctx:
auth.FromContext(ctx)拿到Subject、Scopes、Roles等。 - 签发:
verifier.Issue(auth.IssueRequest{…}, ttl),HMAC 模式下服务内部/测试不必自己 import JWT 库。 - 防用户枚举:「用户不存在」与「密码错」返回同一条消息。
三条路由,两个 tier
这段来自 examples/auth/main.go 的 BuildRouter。
整个授权模型就藏在这几行的顺序里:/login 在第一个
Use 之前注册,所以公开;/profile 在验签中间件之后;
/admin/audit 又在 RequireRole("admin") 之后。
func (s *authDemo) BuildRouter(srv *httpx.Server) error {
// Public: the one route that hands out tokens.
srv.Handle("POST", "/api/v1/login", s.login)
// Everything below this line needs a valid token.
srv.Use(s.verifier.Middleware())
srv.Handle("GET", "/api/v1/profile", profile)
// Middleware accumulates, so this route needs the token *and* the
// admin role. That is the whole authorization model: register public
// routes, then Use, then the next tier.
srv.Use(auth.RequireRole("admin"))
srv.Handle("GET", "/api/v1/admin/audit", audit)
return nil
}
登录与读取主体
同一份文件里的两个 handler。登录那侧演示了签发,注意它给了
Scopes 与 Roles 两项 claim;
profile 那侧演示了读取——它完全不知道 token 是怎么来的。
u, ok := demoUsers[req.Username]
if !ok || u.password != req.Password {
// Same message for "no such user" and "wrong password": telling
// them apart hands an attacker a user-enumeration oracle.
return nil, errs.Unauthorized(40101, "invalid credentials")
}
token, err := s.verifier.Issue(auth.IssueRequest{
Subject: req.Username,
Scopes: u.scopes,
Roles: u.roles,
}, time.Hour)
if err != nil {
return nil, errs.Internal(50001, "issue token: %v", err)
}
return map[string]any{"token": token, "token_type": "Bearer", "expires_in": 3600}, nil
// profile reads the principal the middleware put on the context.
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
}
阶梯与失败语义
flowchart TD
L["POST /api/v1/login
公开 · 换 token"] --> T["verifier.Issue
→ Bearer token"]
T --> M["srv.Use(verifier.Middleware())"]
M --> P["GET /api/v1/profile
需要有效 token"]
P --> R["srv.Use(auth.RequireRole('admin'))"]
R --> A["GET /api/v1/admin/audit
需要 token + admin 角色"]
M -.->|"缺 / 格式错 / 签名错 / 过期"| E401["401 code 40101"]
R -.->|"token 有效但角色不够"| E403["403 code 40301"]
J["JWKS 端点不可达"] -.->|"从未成功拉取过"| E503["503 code 50301"]
classDef bad fill:#fdf0f0,stroke:#a33,color:#2b2722
classDef guard fill:#fdf6f0,stroke:#c8641f,color:#2b2722
class E401,E403 bad
class E503 guard
Use 都往下累积一层要求,所以 tier 就是书写顺序。
三条失败出口刻意分开:凭证问题给 401/403,身份提供方故障给 503——
让客户端知道该换 token 还是该重试。
表格里的第三种情况值得单独记:JWKS 端点不可达返回 503, 因为身份提供方短暂不可用不该让客户端以为自己的 token 坏了。 拉取失败时会继续用上一次的公钥(可用性优先),只有从未成功拉取过才对外报 503。
验证
# 1. 拿 token
curl -s -X POST localhost:8081/api/v1/login -d '{"username":"bob","password":"bob-pw"}'
# {"code":0,"msg":"ok","data":{"expires_in":3600,"token":"eyJ…","token_type":"Bearer"}}
# 2. 无 token
curl -i localhost:8081/api/v1/profile
# 401 {"code":40101,"msg":"…","data":null}
# 3. 带 token
TOKEN=$(curl -s -X POST localhost:8081/api/v1/login -d '{"username":"bob","password":"bob-pw"}' | sed 's/.*"token":"\([^"]*\)".*/\1/')
curl -s localhost:8081/api/v1/profile -H "Authorization: Bearer $TOKEN"
# {"code":0,"msg":"ok","data":{"roles":["admin"],"scopes":["profile:read","audit:read"],"subject":"bob"}}
# 4. admin 路由:bob 可以
curl -s localhost:8081/api/v1/admin/audit -H "Authorization: Bearer $TOKEN"
# {"code":0,"msg":"ok","data":{"entries":["alice logged in","bob rotated the signing key"],"requested_by":"bob"}}
# 5. admin 路由:alice 是普通用户,403
ALICE=$(curl -s -X POST localhost:8081/api/v1/login -d '{"username":"alice","password":"alice-pw"}' | sed 's/.*"token":"\([^"]*\)".*/\1/')
curl -i localhost:8081/api/v1/admin/audit -H "Authorization: Bearer $ALICE"
# 403 {"code":40301,"msg":"…","data":null}
# 6. 凭证错误一律同一条消息
curl -i -X POST localhost:8081/api/v1/login -d '{"username":"nobody","password":"x"}'
# 401 {"code":40101,"msg":"invalid credentials","data":null}
测试
main_test.go 用真实 verifier(auth.New(auth.Config{Issuer:"matex-authdemo", Secret:testSecret}))
搭起完整链路,断言的是:
TestLogin:正确凭证能拿到 token;密码错是 401; 不存在的用户与密码错看起来完全一样——状态 401 且 body 含"invalid credentials"。TestProtectedRoutes五个用例:/profile无 token →401 "code":40101;alice 的 token 访问/profile→ 200 且"subject":"alice";alice 访问/admin/audit→ 403"code":40301;bob 访问 → 200 且"requested_by":"bob";被篡改的 token(末尾加一个字符) →401 "code":40101。TestPublicRouteStaysOpen:/login在任何Use之前注册,所以不带 token 也是 200。TestProfileExposesScopes:bob 的/profile响应体里 同时含"audit:read"与"admin"——claim 完整地走完了往返。
go test ./examples/auth
相关
相关示例
- examples/http:另一个用
Use做守卫的例子,不依赖 JWT。 - examples/feature:多副本统一开关时,管理接口同样该加守卫。
- examples/config:
auth段里的密钥应该用${AUTH_SECRET}从环境注入。