package auth import ( "context" "encoding/json" "log/slog" "net/http" "strings" ) // claimsCtxKey is the context key for a validated Claims set by Authorize. type claimsCtxKey struct{} // ClaimsFromContext returns the validated Claims stored by Authorize. func ClaimsFromContext(ctx context.Context) (*Claims, bool) { claims, ok := ctx.Value(claimsCtxKey{}).(*Claims) 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) { token, ok := bearerToken(r) if !ok { s.log.Debug("missing bearer token") writeAuthError(w, http.StatusUnauthorized, ErrUnauthorized) return } claims, err := s.Verify(token) if err != nil { s.log.Debug("token verify failed", "err", err) writeAuthError(w, http.StatusUnauthorized, ErrUnauthorized) return } 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 { if len(required) == 0 { return true } for _, r := range required { if HasRole(role, r) { return true } } return false } // bearerToken extracts the token from an Authorization: Bearer header. func bearerToken(r *http.Request) (string, bool) { const prefix = "Bearer " h := r.Header.Get("Authorization") if !strings.HasPrefix(h, prefix) { return "", false } token := strings.TrimSpace(h[len(prefix):]) return token, token != "" } // writeAuthError writes an {error:{code,message}} JSON response matching the // project's client contract. No token material is ever logged. func writeAuthError(w http.ResponseWriter, status int, err error) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(map[string]any{ "error": map[string]any{ "code": authErrorCode(status), "message": err.Error(), }, }) } // authErrorCode maps an HTTP status to a stable error code string. func authErrorCode(status int) string { switch status { case http.StatusUnauthorized: return "unauthorized" case http.StatusForbidden: return "forbidden" default: return http.StatusText(status) } }