- 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 显式报错,不再派生回落
194 lines
6.7 KiB
Go
194 lines
6.7 KiB
Go
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)
|
||
}
|
||
}
|