feat(auth,gateway,user): X-API-Key 真接线 + key 一律超管 + owner 档案真落盘(裁决 15.4.2/15.4.3/10.4.5/08.4.2)
- 15.4.2:管理 API 与 Bearer 共用同一 Authorize 路径,X-API-Key 命中签发表即过门;未知/已吊销/空值 401 且不回落 Bearer(fail-closed) - 15.4.3:key 表去掉角色维度,任一有效 key 鉴权后即 owner;签发接口取消 role 分级(不再读请求体) - 10.4.5:新增 GET/PUT /api/v1/admin/owner-profile(owner-only,PUT 带审计 owner.profile.update),0600 原子落盘 + 活档案热替换;前端主人档案改接真实 API,读不到即禁用保存,删掉硬编码默认档案 - 08.4.2:persona 的 identity.systemPrompt 必填,缺失/空白 400 显式报错,不再派生回落
This commit is contained in:
1 parent
8c6466cb00
commit
adef36538f
13 files changed
+1223
-199
No files matched your search
+25
-20
@@ -11,15 +11,16 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// APIKey manages a bounded, in-memory constant table of API keys, each bound to
|
||||
// a Role so keys can be minted for admin/owner to reach administrative
|
||||
// endpoints. Keys are generated with crypto/rand and stored by their string
|
||||
// form. This suits the current single-binary milestone; production should
|
||||
// persist keys (e.g. hashed) and support revocation.
|
||||
// APIKey manages a bounded, in-memory constant table of API keys. 按裁决
|
||||
// 15.4.3(主人原话:「key 始终给超级管理员权限」),表里**不再绑定角色**:
|
||||
// 任何已登记且未吊销的 key 鉴权后一律 owner 级 —— key 用于管理员调试,甚至
|
||||
// 生产环境救火,因此不做权限分级。Keys are generated with crypto/rand and
|
||||
// stored by their string form. This suits the current single-binary milestone;
|
||||
// production should persist keys (e.g. hashed) and support revocation.
|
||||
type APIKey struct {
|
||||
mu sync.RWMutex
|
||||
cfg config.AuthConfig
|
||||
keys map[string]Role
|
||||
keys map[string]struct{}
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
@@ -28,16 +29,18 @@ type APIKey struct {
|
||||
func NewAPIKey(cfg config.AuthConfig) *APIKey {
|
||||
return &APIKey{
|
||||
cfg: cfg,
|
||||
keys: make(map[string]Role),
|
||||
keys: make(map[string]struct{}),
|
||||
log: logger.WithSource(slog.Default(), source),
|
||||
}
|
||||
}
|
||||
|
||||
// Generate mints a new random key bound to role and stores it. Returns the
|
||||
// plaintext key exactly once; it must not be logged.
|
||||
func (k *APIKey) Generate(role Role) (string, error) {
|
||||
if !role.Valid() {
|
||||
return "", fmt.Errorf("%w: %q", ErrInvalidRole, role)
|
||||
// Generate 铸造一个新 key 并登记进表,返回**仅此一次**可见的明文(绝不记录到
|
||||
// 日志)。role 是可选形参:传入时只校验它是不是已知角色名(未知即报
|
||||
// ErrInvalidRole),**不参与存储、不影响授权** —— 按裁决 15.4.3,key 表已无
|
||||
// 角色维度,任何有效 key 都是超管级(见 Lookup)。
|
||||
func (k *APIKey) Generate(role ...Role) (string, error) {
|
||||
if len(role) > 0 && !role[0].Valid() {
|
||||
return "", fmt.Errorf("%w: %q", ErrInvalidRole, role[0])
|
||||
}
|
||||
raw := make([]byte, keyLength)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
@@ -46,7 +49,7 @@ func (k *APIKey) Generate(role Role) (string, error) {
|
||||
key := base64.RawURLEncoding.EncodeToString(raw)
|
||||
|
||||
k.mu.Lock()
|
||||
k.keys[key] = role
|
||||
k.keys[key] = struct{}{}
|
||||
k.mu.Unlock()
|
||||
return key, nil
|
||||
}
|
||||
@@ -57,12 +60,13 @@ func (k *APIKey) Verify(key string) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
// Lookup returns the role bound to key.
|
||||
// Lookup 报告 key 是否已登记,并返回其授权角色。按裁决 15.4.3,**任何**已登记
|
||||
// 的 key 都是超管(owner)级。key 不在表中时 ok 为 false,调用方必须据此拒绝。
|
||||
func (k *APIKey) Lookup(key string) (Role, bool) {
|
||||
k.mu.RLock()
|
||||
defer k.mu.RUnlock()
|
||||
role, ok := k.keys[key]
|
||||
return role, ok
|
||||
_, ok := k.keys[key]
|
||||
return RoleOwner, ok
|
||||
}
|
||||
|
||||
// Revoke removes key from the constant table.
|
||||
@@ -79,7 +83,7 @@ func (k *APIKey) RevokeAll() int {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
n := len(k.keys)
|
||||
k.keys = make(map[string]Role)
|
||||
k.keys = make(map[string]struct{})
|
||||
return n
|
||||
}
|
||||
|
||||
@@ -106,13 +110,14 @@ func (k *APIKey) RevokeByMask(mask string) bool {
|
||||
// List returns a summary of every currently registered API key for the admin
|
||||
// panel. Key material is never returned; each identifier is masked so the panel
|
||||
// can tell keys apart without exposing the secret. The in-memory store keeps no
|
||||
// creation time, so CreatedAt is nil for every entry.
|
||||
// creation time, so CreatedAt is nil for every entry. 每条的角色恒为 owner
|
||||
// (裁决 15.4.3:key 不做角色分级)。
|
||||
func (k *APIKey) List() []UserSummary {
|
||||
k.mu.RLock()
|
||||
defer k.mu.RUnlock()
|
||||
out := make([]UserSummary, 0, len(k.keys))
|
||||
for key, role := range k.keys {
|
||||
out = append(out, UserSummary{ID: maskKey(key), Username: "apikey", Role: role})
|
||||
for key := range k.keys {
|
||||
out = append(out, UserSummary{ID: maskKey(key), Username: "apikey", Role: RoleOwner})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ func newTestAPIKey() *APIKey {
|
||||
func TestAPIKey_GenerateVerify(t *testing.T) {
|
||||
t.Parallel()
|
||||
k := newTestAPIKey()
|
||||
key, err := k.Generate(RoleAdmin)
|
||||
key, err := k.Generate()
|
||||
if err != nil {
|
||||
t.Fatalf("Generate error: %v", err)
|
||||
}
|
||||
@@ -24,20 +24,45 @@ func TestAPIKey_GenerateVerify(t *testing.T) {
|
||||
if !k.Verify(key) {
|
||||
t.Errorf("Verify(%q) = false, want true", key)
|
||||
}
|
||||
// 裁决 15.4.3:任何有效 key 都是超管级,不按 role 分档。
|
||||
role, ok := k.Lookup(key)
|
||||
if !ok || role != RoleAdmin {
|
||||
t.Errorf("Lookup(%q) = (%q,%v), want (admin,true)", key, role, ok)
|
||||
if !ok || role != RoleOwner {
|
||||
t.Errorf("Lookup(%q) = (%q,%v), want (owner,true)", key, role, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPIKey_GenerateIgnoresLegacyRole 钉住「key 不做角色分级」这条裁决的
|
||||
// 可观察后果:即使调用方沿旧签名传了 admin/viewer,签出来的 key 仍然是
|
||||
// 超管级,且表里不留任何角色维度。
|
||||
func TestAPIKey_GenerateIgnoresLegacyRole(t *testing.T) {
|
||||
t.Parallel()
|
||||
k := newTestAPIKey()
|
||||
for _, want := range []Role{RoleAdmin, RoleViewer} {
|
||||
key, err := k.Generate(want)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate(%q) error: %v", want, err)
|
||||
}
|
||||
role, ok := k.Lookup(key)
|
||||
if !ok || role != RoleOwner {
|
||||
t.Fatalf("Generate(%q) → Lookup = (%q,%v), want (owner,true)", want, role, ok)
|
||||
}
|
||||
}
|
||||
// 列表也一律如实报 owner,面板据此没有分级可显示。
|
||||
for _, s := range k.List() {
|
||||
if s.Role != RoleOwner {
|
||||
t.Fatalf("List() entry role = %q, want owner", s.Role)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKey_GenerateUnique(t *testing.T) {
|
||||
t.Parallel()
|
||||
k := newTestAPIKey()
|
||||
a, err := k.Generate(RoleViewer)
|
||||
a, err := k.Generate()
|
||||
if err != nil {
|
||||
t.Fatalf("Generate error: %v", err)
|
||||
}
|
||||
b, err := k.Generate(RoleViewer)
|
||||
b, err := k.Generate()
|
||||
if err != nil {
|
||||
t.Fatalf("Generate error: %v", err)
|
||||
}
|
||||
@@ -68,7 +93,7 @@ func TestAPIKey_VerifyUnknown(t *testing.T) {
|
||||
func TestAPIKey_Revoke(t *testing.T) {
|
||||
t.Parallel()
|
||||
k := newTestAPIKey()
|
||||
key, err := k.Generate(RoleViewer)
|
||||
key, err := k.Generate()
|
||||
if err != nil {
|
||||
t.Fatalf("Generate error: %v", err)
|
||||
}
|
||||
@@ -85,8 +110,8 @@ func TestAPIKey_RevokeAll(t *testing.T) {
|
||||
t.Parallel()
|
||||
k := newTestAPIKey()
|
||||
keys := make([]string, 0, 3)
|
||||
for _, role := range []Role{RoleOwner, RoleAdmin, RoleViewer} {
|
||||
key, err := k.Generate(role)
|
||||
for range 3 {
|
||||
key, err := k.Generate()
|
||||
if err != nil {
|
||||
t.Fatalf("Generate error: %v", err)
|
||||
}
|
||||
|
||||
+101
-7
@@ -3,6 +3,7 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
@@ -16,12 +17,29 @@ func ClaimsFromContext(ctx context.Context) (*Claims, bool) {
|
||||
return claims, ok
|
||||
}
|
||||
|
||||
// apiKeyHeader 是 API key 凭据的请求头(裁决 15.4.2)。
|
||||
const apiKeyHeader = "X-API-Key"
|
||||
|
||||
// apiKeySubPrefix 是 API key 分支写入 Claims.Sub 的前缀。Sub 取**脱敏** id
|
||||
// 而不是明文,使审计链能定位是哪把 key,却永不记录密钥材料。
|
||||
const apiKeySubPrefix = "apikey:"
|
||||
|
||||
// KeyTable 是 API key 表的最小读接口(*APIKey 满足),使下面的中间件不依赖
|
||||
// 具体存储实现。
|
||||
type KeyTable interface {
|
||||
// Lookup 报告 key 是否已登记,并返回其授权角色。
|
||||
Lookup(key string) (Role, bool)
|
||||
}
|
||||
|
||||
// Authorize returns an HTTP middleware that requires a valid Bearer JWT whose
|
||||
// role satisfies at least one of the required roles. With no required roles it
|
||||
// admits any authenticated call. A missing/invalid/expired token yields 401
|
||||
// (ErrUnauthorized); an authenticated token with insufficient privilege yields
|
||||
// 403 (ErrForbidden). The validated claims are stored in the request context
|
||||
// for downstream handlers.
|
||||
//
|
||||
// 本方法只认 Bearer;`X-API-Key` 凭据由 WithAPIKey 叠加(裁决 15.4.2),
|
||||
// 两条分支共用同一段角色判定与上下文注入逻辑(claimGate)。
|
||||
func (s *JWTService) Authorize(required ...Role) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -37,17 +55,93 @@ func (s *JWTService) Authorize(required ...Role) func(http.Handler) http.Handler
|
||||
writeAuthError(w, http.StatusUnauthorized, ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
if !satisfies(claims.Role, required) {
|
||||
s.log.Debug("insufficient role", "role", claims.Role)
|
||||
writeAuthError(w, http.StatusForbidden, ErrForbidden)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), claimsCtxKey{}, claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
claimGate{log: s.log, required: required}.admit(w, r, claims, next)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// WithAPIKey 在既有凭据门 bearer 之上叠加 `X-API-Key` 分支(裁决 15.4.2),
|
||||
// 使签发出去的 key 真的能用:
|
||||
//
|
||||
// - 请求带 `X-API-Key` 头时**只**走 key 分支 —— 命中 keys 表即以 owner 级
|
||||
// 过门(裁决 15.4.3「key 一律超级管理员」,不做角色分档;owner 满足一切
|
||||
// 角色要求);为空值、未知或已吊销一律 401(fail-closed),**绝不回落到
|
||||
// Bearer**,因此一把坏 key 无法借道另一份凭据混进门;
|
||||
// - 不带该头时完全交给 bearer,Bearer JWT 路径的行为一字不变。
|
||||
//
|
||||
// keys 为 nil 表示 key 表未注入(等同空表):此时带头的请求一律 401。
|
||||
func WithAPIKey(
|
||||
keys KeyTable,
|
||||
bearer func(http.Handler) http.Handler,
|
||||
log *slog.Logger,
|
||||
required ...Role,
|
||||
) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
// Bearer 门在注册期构建一次(与原 Authorize 的分配方式一致),
|
||||
// 不走 key 分支的请求照旧零额外分配。
|
||||
bearerDoor := bearer(next)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
key, present := apiKeyFromRequest(r)
|
||||
if !present {
|
||||
bearerDoor.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
claims, ok := lookupAPIKeyClaims(keys, key)
|
||||
if !ok {
|
||||
log.Debug("api key rejected", "reason", "absent table, unknown or revoked key")
|
||||
writeAuthError(w, http.StatusUnauthorized, ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
claimGate{log: log, required: required}.admit(w, r, claims, next)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// claimGate 是两种凭据(Bearer JWT 与 X-API-Key)共用的后半段:角色判定 →
|
||||
// 403/放行 → 已验证 claims 进请求上下文。这正是裁决 15.4.2 要求的「与 Bearer
|
||||
// 走同一 Authorize 路径」。
|
||||
type claimGate struct {
|
||||
log *slog.Logger
|
||||
required []Role
|
||||
}
|
||||
|
||||
// admit 在 claims 满足 required 时把 claims 放进请求上下文并交给 next,
|
||||
// 否则回 403。
|
||||
func (g claimGate) admit(w http.ResponseWriter, r *http.Request, claims *Claims, next http.Handler) {
|
||||
if !satisfies(claims.Role, g.required) {
|
||||
g.log.Debug("insufficient role", "role", claims.Role)
|
||||
writeAuthError(w, http.StatusForbidden, ErrForbidden)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), claimsCtxKey{}, claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
}
|
||||
|
||||
// apiKeyFromRequest 取出 `X-API-Key` 头的值。present 报告该头是否**出现**
|
||||
// (即使值为空)——出现即表示调用方选择了 key 凭据,因此空值也按无效凭据
|
||||
// 拒绝,而不是悄悄回落到 Bearer。
|
||||
func apiKeyFromRequest(r *http.Request) (string, bool) {
|
||||
values := r.Header.Values(apiKeyHeader)
|
||||
if len(values) == 0 {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(values[0]), true
|
||||
}
|
||||
|
||||
// lookupAPIKeyClaims 校验 key 并映射为 Claims。按裁决 15.4.3,任何已登记且
|
||||
// 未吊销的 key 都是超管级(由表给出角色,见 APIKey.Lookup)。空 key 与「表未
|
||||
// 注入」都直接失败:fail-closed。
|
||||
func lookupAPIKeyClaims(keys KeyTable, key string) (*Claims, bool) {
|
||||
if keys == nil || key == "" {
|
||||
return nil, false
|
||||
}
|
||||
role, ok := keys.Lookup(key)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return &Claims{Sub: apiKeySubPrefix + MaskKey(key), Role: role}, true
|
||||
}
|
||||
|
||||
// satisfies reports whether role has enough privilege for at least one of the
|
||||
// required roles. An empty required list means any authenticated role passes.
|
||||
func satisfies(role Role, required []Role) bool {
|
||||
|
||||
@@ -3,6 +3,8 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -172,3 +174,133 @@ func TestClaimsFromContext_Missing(t *testing.T) {
|
||||
t.Fatal("ClaimsFromContext on empty ctx should return ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
// --- X-API-Key 分支(裁决 15.4.2 / 15.4.3)-----------------------------------
|
||||
|
||||
// discardLog 是中间件测试用的丢弃型 logger(不产出日志噪声)。
|
||||
func discardLog() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// keyDoor 构建「Bearer + X-API-Key」双凭据门。
|
||||
func keyDoor(keys KeyTable, required ...Role) func(http.Handler) http.Handler {
|
||||
return WithAPIKey(keys, New(testConfig("")).Authorize(required...), discardLog(), required...)
|
||||
}
|
||||
|
||||
// doAPIKey 发一个只带 `X-API-Key` 头的请求。
|
||||
func doAPIKey(h http.Handler, key string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set(apiKeyHeader, key)
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
// TestWithAPIKey_ValidKeyPassesOwnerGate 是 15.4.2 的核心断言:签发出去的 key
|
||||
// 真的能用,并且拿到 owner 级(15.4.3),Sub 是脱敏 id 而不是密钥明文。
|
||||
func TestWithAPIKey_ValidKeyPassesOwnerGate(t *testing.T) {
|
||||
t.Parallel()
|
||||
keys := newTestAPIKey()
|
||||
key, err := keys.Generate()
|
||||
if err != nil {
|
||||
t.Fatalf("Generate error: %v", err)
|
||||
}
|
||||
rr := doAPIKey(keyDoor(keys, RoleOwner)(claimsHandler(t)), key)
|
||||
responseCode(t, rr, http.StatusOK)
|
||||
if got := rr.Header().Get("X-Claims-Role"); got != string(RoleOwner) {
|
||||
t.Errorf("X-Claims-Role = %q, want owner", got)
|
||||
}
|
||||
if got := rr.Header().Get("X-Claims-Sub"); got != apiKeySubPrefix+MaskKey(key) {
|
||||
t.Errorf("X-Claims-Sub = %q, want masked id %q", got, apiKeySubPrefix+MaskKey(key))
|
||||
}
|
||||
if strings.Contains(rr.Header().Get("X-Claims-Sub"), key) {
|
||||
t.Error("claims Sub leaked the key material")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithAPIKey_UnknownKeyRejected 断言未知 key 一律 401,且**即使同时带着一份
|
||||
// 合法 Bearer 也不回落** —— fail-closed,坏 key 不能借道另一份凭据混过门。
|
||||
func TestWithAPIKey_UnknownKeyRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
keys := newTestAPIKey()
|
||||
door := keyDoor(keys, RoleOwner)
|
||||
rr := doAPIKey(door(claimsHandler(t)), "not-a-key")
|
||||
responseCode(t, rr, http.StatusUnauthorized)
|
||||
assertErrorCode(t, rr, "unauthorized")
|
||||
|
||||
svc := New(testConfig(""))
|
||||
tok, err := svc.Sign("u", RoleOwner, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign error: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set(apiKeyHeader, "not-a-key")
|
||||
req.Header.Set("Authorization", bearerHeader(tok))
|
||||
rec := httptest.NewRecorder()
|
||||
WithAPIKey(keys, svc.Authorize(RoleOwner), discardLog(), RoleOwner)(claimsHandler(t)).ServeHTTP(rec, req)
|
||||
responseCode(t, rec, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
// TestWithAPIKey_RevokedKeyRejected 断言吊销后的 key 立即失效(能发也能废)。
|
||||
func TestWithAPIKey_RevokedKeyRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
keys := newTestAPIKey()
|
||||
key, err := keys.Generate()
|
||||
if err != nil {
|
||||
t.Fatalf("Generate error: %v", err)
|
||||
}
|
||||
door := keyDoor(keys, RoleOwner)
|
||||
responseCode(t, doAPIKey(door(claimsHandler(t)), key), http.StatusOK)
|
||||
keys.Revoke(key)
|
||||
responseCode(t, doAPIKey(door(claimsHandler(t)), key), http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
// TestWithAPIKey_EmptyHeaderValueRejected 断言该头出现但值为空时按无效凭据拒绝,
|
||||
// 而不是被当成「没带 key」悄悄回落到 Bearer。
|
||||
func TestWithAPIKey_EmptyHeaderValueRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
keys := newTestAPIKey()
|
||||
if _, err := keys.Generate(); err != nil {
|
||||
t.Fatalf("Generate error: %v", err)
|
||||
}
|
||||
rr := doAPIKey(keyDoor(keys, RoleOwner)(claimsHandler(t)), "")
|
||||
responseCode(t, rr, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
// TestWithAPIKey_NilTableRejectsKeyHeader 断言 key 表未注入时带 key 的请求一律
|
||||
// 拒绝(表不在 = 表为空),不带 key 的请求则照旧走 Bearer。
|
||||
func TestWithAPIKey_NilTableRejectsKeyHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := New(testConfig(""))
|
||||
door := WithAPIKey(nil, svc.Authorize(RoleOwner), discardLog(), RoleOwner)
|
||||
responseCode(t, doAPIKey(door(claimsHandler(t)), "any-key"), http.StatusUnauthorized)
|
||||
|
||||
tok, err := svc.Sign("u", RoleOwner, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign error: %v", err)
|
||||
}
|
||||
rr := doAuth(door(claimsHandler(t)), tok)
|
||||
responseCode(t, rr, http.StatusOK)
|
||||
}
|
||||
|
||||
// TestWithAPIKey_BearerPathUnchanged 断言不带该头时行为与纯 Bearer 门逐字一致:
|
||||
// 角色不足仍 403、缺凭据仍 401。
|
||||
func TestWithAPIKey_BearerPathUnchanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := New(testConfig(""))
|
||||
keys := newTestAPIKey()
|
||||
door := WithAPIKey(keys, svc.Authorize(RoleOwner), discardLog(), RoleOwner)
|
||||
|
||||
admin, err := svc.Sign("u", RoleAdmin, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign error: %v", err)
|
||||
}
|
||||
responseCode(t, doAuth(door(claimsHandler(t)), admin), http.StatusForbidden)
|
||||
responseCode(t, doAuth(door(claimsHandler(t)), ""), http.StatusUnauthorized)
|
||||
|
||||
owner, err := svc.Sign("u", RoleOwner, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign error: %v", err)
|
||||
}
|
||||
responseCode(t, doAuth(door(claimsHandler(t)), owner), http.StatusOK)
|
||||
}
|
||||
Reference in new issue
Block a user