feat: sync lijiaoqiao implementation and staging validation artifacts
This commit is contained in:
6
platform-token-runtime/.dockerignore
Normal file
6
platform-token-runtime/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
.git
|
||||
.tools
|
||||
reports
|
||||
review
|
||||
tests
|
||||
**/*_test.go
|
||||
13
platform-token-runtime/Dockerfile
Normal file
13
platform-token-runtime/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM golang:1.22-alpine AS builder
|
||||
WORKDIR /src
|
||||
|
||||
COPY go.mod ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/platform-token-runtime ./cmd/platform-token-runtime
|
||||
|
||||
FROM gcr.io/distroless/static-debian12
|
||||
WORKDIR /app
|
||||
COPY --from=builder /out/platform-token-runtime /app/platform-token-runtime
|
||||
EXPOSE 18081
|
||||
ENTRYPOINT ["/app/platform-token-runtime"]
|
||||
41
platform-token-runtime/README.md
Normal file
41
platform-token-runtime/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# platform-token-runtime(TOK-002/003/004 开发实现)
|
||||
|
||||
本目录用于承载 token 运行态的开发阶段实现,不依赖真实 staging 参数。
|
||||
|
||||
## 文件说明
|
||||
|
||||
1. `cmd/platform-token-runtime/main.go`:可执行服务入口(HTTP + 健康检查)。
|
||||
2. `internal/httpapi/token_api.go`:`issue/refresh/revoke/introspect` 接口处理。
|
||||
3. `internal/httpapi/token_api_test.go`:HTTP 接口单测。
|
||||
4. `internal/auth/middleware/*`:TOK-002 中间件与单测。
|
||||
2. `internal/auth/service/token_verifier.go`:鉴权依赖接口、错误码、审计事件常量。
|
||||
3. `internal/auth/service/inmemory_runtime.go`:开发阶段最小可运行内存实现(签发/续期/吊销/introspect + 鉴权接口实现)。
|
||||
4. `internal/token/*_template_test.go`:TOK-003/004 测试模板(按 `TOK-LIFE-*`/`TOK-AUD-*` 对齐)。
|
||||
5. `internal/token/*_executable_test.go`:已转可执行用例(`TOK-LIFE-001~008`、`TOK-AUD-001~007`)。
|
||||
6. `Dockerfile`:运行时镜像构建工件。
|
||||
|
||||
## 设计边界
|
||||
|
||||
1. 仅支持 `Authorization: Bearer <token>` 入站。
|
||||
2. 外部 query key (`key/api_key/token`) 一律拒绝。
|
||||
3. 不在任何响应或审计字段中输出上游凭证明文。
|
||||
|
||||
## 本地测试
|
||||
|
||||
```bash
|
||||
cd "/home/long/project/立交桥/platform-token-runtime"
|
||||
export PATH="/home/long/project/立交桥/.tools/go-current/bin:$PATH"
|
||||
export GOCACHE="/tmp/go-cache"
|
||||
export GOPATH="/tmp/go"
|
||||
go test ./...
|
||||
```
|
||||
|
||||
## 本地运行
|
||||
|
||||
```bash
|
||||
cd "/home/long/project/立交桥/platform-token-runtime"
|
||||
export PATH="/home/long/project/立交桥/.tools/go-current/bin:$PATH"
|
||||
go run ./cmd/platform-token-runtime
|
||||
```
|
||||
|
||||
服务默认监听 `:18081`,可通过 `TOKEN_RUNTIME_ADDR` 覆盖。
|
||||
63
platform-token-runtime/cmd/platform-token-runtime/main.go
Normal file
63
platform-token-runtime/cmd/platform-token-runtime/main.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"lijiaoqiao/platform-token-runtime/internal/auth/service"
|
||||
"lijiaoqiao/platform-token-runtime/internal/httpapi"
|
||||
)
|
||||
|
||||
func main() {
|
||||
addr := envOrDefault("TOKEN_RUNTIME_ADDR", ":18081")
|
||||
|
||||
runtime := service.NewInMemoryTokenRuntime(nil)
|
||||
auditor := service.NewMemoryAuditEmitter()
|
||||
api := httpapi.NewTokenAPI(runtime, auditor, time.Now)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/actuator/health", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"status":"UP"}`))
|
||||
})
|
||||
api.Register(mux)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("platform-token-runtime listening on %s", addr)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("listen failed: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigCh
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Printf("graceful shutdown failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func envOrDefault(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
3
platform-token-runtime/go.mod
Normal file
3
platform-token-runtime/go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module lijiaoqiao/platform-token-runtime
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,51 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lijiaoqiao/platform-token-runtime/internal/auth/service"
|
||||
)
|
||||
|
||||
var disallowedQueryKeys = []string{"key", "api_key", "token"}
|
||||
|
||||
func QueryKeyRejectMiddleware(next http.Handler, auditor service.AuditEmitter, now func() time.Time) http.Handler {
|
||||
if next == nil {
|
||||
next = http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
|
||||
}
|
||||
if now == nil {
|
||||
now = defaultNowFunc
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, exists := externalQueryKey(r)
|
||||
if !exists {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
requestID := ensureRequestID(r, now)
|
||||
emitAuditEvent(r.Context(), auditor, service.AuditEvent{
|
||||
EventName: service.EventTokenQueryKeyRejected,
|
||||
RequestID: requestID,
|
||||
Route: r.URL.Path,
|
||||
ResultCode: service.CodeQueryKeyNotAllowed,
|
||||
ClientIP: extractClientIP(r),
|
||||
CreatedAt: now(),
|
||||
})
|
||||
writeError(w, http.StatusUnauthorized, requestID, service.CodeQueryKeyNotAllowed, "query key ingress is not allowed")
|
||||
})
|
||||
}
|
||||
|
||||
func externalQueryKey(r *http.Request) (string, bool) {
|
||||
values := r.URL.Query()
|
||||
for key := range values {
|
||||
lowered := strings.ToLower(key)
|
||||
for _, disallowed := range disallowedQueryKeys {
|
||||
if lowered == disallowed {
|
||||
return key, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lijiaoqiao/platform-token-runtime/internal/auth/model"
|
||||
"lijiaoqiao/platform-token-runtime/internal/auth/service"
|
||||
)
|
||||
|
||||
const requestIDHeader = "X-Request-Id"
|
||||
|
||||
var defaultNowFunc = time.Now
|
||||
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
requestIDKey contextKey = "request_id"
|
||||
principalKey contextKey = "principal"
|
||||
)
|
||||
|
||||
type AuthMiddlewareConfig struct {
|
||||
Verifier service.TokenVerifier
|
||||
StatusResolver service.TokenStatusResolver
|
||||
Authorizer service.RouteAuthorizer
|
||||
Auditor service.AuditEmitter
|
||||
ProtectedPrefixes []string
|
||||
ExcludedPrefixes []string
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func BuildTokenAuthChain(cfg AuthMiddlewareConfig, next http.Handler) http.Handler {
|
||||
handler := TokenAuthMiddleware(cfg)(next)
|
||||
handler = QueryKeyRejectMiddleware(handler, cfg.Auditor, cfg.Now)
|
||||
handler = RequestIDMiddleware(handler, cfg.Now)
|
||||
return handler
|
||||
}
|
||||
|
||||
func RequestIDMiddleware(next http.Handler, now func() time.Time) http.Handler {
|
||||
if next == nil {
|
||||
return http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
|
||||
}
|
||||
if now == nil {
|
||||
now = defaultNowFunc
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestID := ensureRequestID(r, now)
|
||||
w.Header().Set(requestIDHeader, requestID)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func TokenAuthMiddleware(cfg AuthMiddlewareConfig) func(http.Handler) http.Handler {
|
||||
cfg = cfg.withDefaults()
|
||||
return func(next http.Handler) http.Handler {
|
||||
if next == nil {
|
||||
next = http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !cfg.shouldProtect(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
requestID := ensureRequestID(r, cfg.Now)
|
||||
if cfg.Verifier == nil || cfg.StatusResolver == nil || cfg.Authorizer == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, requestID, service.CodeAuthNotReady, "auth middleware dependencies are not ready")
|
||||
return
|
||||
}
|
||||
|
||||
rawToken, ok := extractBearerToken(r.Header.Get("Authorization"))
|
||||
if !ok {
|
||||
emitAuditEvent(r.Context(), cfg.Auditor, service.AuditEvent{
|
||||
EventName: service.EventTokenAuthnFail,
|
||||
RequestID: requestID,
|
||||
Route: r.URL.Path,
|
||||
ResultCode: service.CodeAuthMissingBearer,
|
||||
ClientIP: extractClientIP(r),
|
||||
CreatedAt: cfg.Now(),
|
||||
})
|
||||
writeError(w, http.StatusUnauthorized, requestID, service.CodeAuthMissingBearer, "missing bearer token")
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := cfg.Verifier.Verify(r.Context(), rawToken)
|
||||
if err != nil {
|
||||
emitAuditEvent(r.Context(), cfg.Auditor, service.AuditEvent{
|
||||
EventName: service.EventTokenAuthnFail,
|
||||
RequestID: requestID,
|
||||
Route: r.URL.Path,
|
||||
ResultCode: service.CodeAuthInvalidToken,
|
||||
ClientIP: extractClientIP(r),
|
||||
CreatedAt: cfg.Now(),
|
||||
})
|
||||
writeError(w, http.StatusUnauthorized, requestID, service.CodeAuthInvalidToken, "invalid bearer token")
|
||||
return
|
||||
}
|
||||
|
||||
tokenStatus, err := cfg.StatusResolver.Resolve(r.Context(), claims.TokenID)
|
||||
if err != nil || tokenStatus != service.TokenStatusActive {
|
||||
emitAuditEvent(r.Context(), cfg.Auditor, service.AuditEvent{
|
||||
EventName: service.EventTokenAuthnFail,
|
||||
RequestID: requestID,
|
||||
TokenID: claims.TokenID,
|
||||
SubjectID: claims.SubjectID,
|
||||
Route: r.URL.Path,
|
||||
ResultCode: service.CodeAuthTokenInactive,
|
||||
ClientIP: extractClientIP(r),
|
||||
CreatedAt: cfg.Now(),
|
||||
})
|
||||
writeError(w, http.StatusUnauthorized, requestID, service.CodeAuthTokenInactive, "token is inactive")
|
||||
return
|
||||
}
|
||||
|
||||
if !cfg.Authorizer.Authorize(r.URL.Path, r.Method, claims.Scope, claims.Role) {
|
||||
emitAuditEvent(r.Context(), cfg.Auditor, service.AuditEvent{
|
||||
EventName: service.EventTokenAuthzDenied,
|
||||
RequestID: requestID,
|
||||
TokenID: claims.TokenID,
|
||||
SubjectID: claims.SubjectID,
|
||||
Route: r.URL.Path,
|
||||
ResultCode: service.CodeAuthScopeDenied,
|
||||
ClientIP: extractClientIP(r),
|
||||
CreatedAt: cfg.Now(),
|
||||
})
|
||||
writeError(w, http.StatusForbidden, requestID, service.CodeAuthScopeDenied, "scope denied")
|
||||
return
|
||||
}
|
||||
|
||||
principal := model.Principal{
|
||||
RequestID: requestID,
|
||||
TokenID: claims.TokenID,
|
||||
SubjectID: claims.SubjectID,
|
||||
Role: claims.Role,
|
||||
Scope: append([]string(nil), claims.Scope...),
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), principalKey, principal)
|
||||
ctx = context.WithValue(ctx, requestIDKey, requestID)
|
||||
|
||||
emitAuditEvent(ctx, cfg.Auditor, service.AuditEvent{
|
||||
EventName: service.EventTokenAuthnSuccess,
|
||||
RequestID: requestID,
|
||||
TokenID: claims.TokenID,
|
||||
SubjectID: claims.SubjectID,
|
||||
Route: r.URL.Path,
|
||||
ResultCode: "OK",
|
||||
ClientIP: extractClientIP(r),
|
||||
CreatedAt: cfg.Now(),
|
||||
})
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func RequestIDFromContext(ctx context.Context) (string, bool) {
|
||||
if ctx == nil {
|
||||
return "", false
|
||||
}
|
||||
value, ok := ctx.Value(requestIDKey).(string)
|
||||
return value, ok
|
||||
}
|
||||
|
||||
func PrincipalFromContext(ctx context.Context) (model.Principal, bool) {
|
||||
if ctx == nil {
|
||||
return model.Principal{}, false
|
||||
}
|
||||
value, ok := ctx.Value(principalKey).(model.Principal)
|
||||
return value, ok
|
||||
}
|
||||
|
||||
func (cfg AuthMiddlewareConfig) withDefaults() AuthMiddlewareConfig {
|
||||
if cfg.Now == nil {
|
||||
cfg.Now = defaultNowFunc
|
||||
}
|
||||
if len(cfg.ProtectedPrefixes) == 0 {
|
||||
cfg.ProtectedPrefixes = []string{"/api/v1/supply", "/api/v1/platform"}
|
||||
}
|
||||
if len(cfg.ExcludedPrefixes) == 0 {
|
||||
cfg.ExcludedPrefixes = []string{"/healthz", "/metrics", "/readyz"}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (cfg AuthMiddlewareConfig) shouldProtect(path string) bool {
|
||||
for _, prefix := range cfg.ExcludedPrefixes {
|
||||
if strings.HasPrefix(path, prefix) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, prefix := range cfg.ProtectedPrefixes {
|
||||
if strings.HasPrefix(path, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ensureRequestID(r *http.Request, now func() time.Time) string {
|
||||
if now == nil {
|
||||
now = defaultNowFunc
|
||||
}
|
||||
if requestID, ok := RequestIDFromContext(r.Context()); ok && requestID != "" {
|
||||
return requestID
|
||||
}
|
||||
requestID := strings.TrimSpace(r.Header.Get(requestIDHeader))
|
||||
if requestID == "" {
|
||||
requestID = fmt.Sprintf("req-%d", now().UnixNano())
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), requestIDKey, requestID)
|
||||
*r = *r.WithContext(ctx)
|
||||
return requestID
|
||||
}
|
||||
|
||||
func extractBearerToken(authHeader string) (string, bool) {
|
||||
const bearerPrefix = "Bearer "
|
||||
if !strings.HasPrefix(authHeader, bearerPrefix) {
|
||||
return "", false
|
||||
}
|
||||
token := strings.TrimSpace(strings.TrimPrefix(authHeader, bearerPrefix))
|
||||
return token, token != ""
|
||||
}
|
||||
|
||||
func emitAuditEvent(ctx context.Context, auditor service.AuditEmitter, event service.AuditEvent) {
|
||||
if auditor == nil {
|
||||
return
|
||||
}
|
||||
_ = auditor.Emit(ctx, event)
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
RequestID string `json:"request_id"`
|
||||
Error errorPayload `json:"error"`
|
||||
}
|
||||
|
||||
type errorPayload struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, requestID, code, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
payload := errorResponse{
|
||||
RequestID: requestID,
|
||||
Error: errorPayload{
|
||||
Code: code,
|
||||
Message: message,
|
||||
},
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
func extractClientIP(r *http.Request) string {
|
||||
xForwardedFor := strings.TrimSpace(r.Header.Get("X-Forwarded-For"))
|
||||
if xForwardedFor != "" {
|
||||
parts := strings.Split(xForwardedFor, ",")
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err == nil {
|
||||
return host
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lijiaoqiao/platform-token-runtime/internal/auth/model"
|
||||
"lijiaoqiao/platform-token-runtime/internal/auth/service"
|
||||
)
|
||||
|
||||
var fixedNow = func() time.Time {
|
||||
return time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
type fakeVerifier struct {
|
||||
token service.VerifiedToken
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeVerifier) Verify(context.Context, string) (service.VerifiedToken, error) {
|
||||
return f.token, f.err
|
||||
}
|
||||
|
||||
type fakeStatusResolver struct {
|
||||
status service.TokenStatus
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeStatusResolver) Resolve(context.Context, string) (service.TokenStatus, error) {
|
||||
return f.status, f.err
|
||||
}
|
||||
|
||||
type fakeAuthorizer struct {
|
||||
allowed bool
|
||||
}
|
||||
|
||||
func (f *fakeAuthorizer) Authorize(string, string, []string, string) bool {
|
||||
return f.allowed
|
||||
}
|
||||
|
||||
type fakeAuditor struct {
|
||||
events []service.AuditEvent
|
||||
}
|
||||
|
||||
func (f *fakeAuditor) Emit(_ context.Context, event service.AuditEvent) error {
|
||||
f.events = append(f.events, event)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestQueryKeyRejectMiddleware(t *testing.T) {
|
||||
auditor := &fakeAuditor{}
|
||||
nextCalled := false
|
||||
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
nextCalled = true
|
||||
})
|
||||
handler := QueryKeyRejectMiddleware(next, auditor, fixedNow)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/supply/accounts?api_key=secret", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if nextCalled {
|
||||
t.Fatalf("next handler should not be called when query key exists")
|
||||
}
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unexpected status code: got=%d want=%d", rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
if got := decodeErrorCode(t, rec); got != service.CodeQueryKeyNotAllowed {
|
||||
t.Fatalf("unexpected error code: got=%s want=%s", got, service.CodeQueryKeyNotAllowed)
|
||||
}
|
||||
if len(auditor.events) != 1 {
|
||||
t.Fatalf("unexpected audit event count: got=%d want=1", len(auditor.events))
|
||||
}
|
||||
if auditor.events[0].EventName != service.EventTokenQueryKeyRejected {
|
||||
t.Fatalf("unexpected event name: got=%s want=%s", auditor.events[0].EventName, service.EventTokenQueryKeyRejected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenAuthMiddleware(t *testing.T) {
|
||||
baseToken := service.VerifiedToken{
|
||||
TokenID: "tok-001",
|
||||
SubjectID: "subject-001",
|
||||
Role: model.RoleOwner,
|
||||
Scope: []string{"supply:*"},
|
||||
IssuedAt: fixedNow(),
|
||||
ExpiresAt: fixedNow().Add(time.Hour),
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
authHeader string
|
||||
verifierErr error
|
||||
status service.TokenStatus
|
||||
statusErr error
|
||||
allowed bool
|
||||
wantStatus int
|
||||
wantErrorCode string
|
||||
wantEvent string
|
||||
wantNext bool
|
||||
}{
|
||||
{
|
||||
name: "missing bearer",
|
||||
path: "/api/v1/supply/packages",
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
wantErrorCode: service.CodeAuthMissingBearer,
|
||||
wantEvent: service.EventTokenAuthnFail,
|
||||
},
|
||||
{
|
||||
name: "invalid token",
|
||||
path: "/api/v1/supply/packages",
|
||||
authHeader: "Bearer invalid-token",
|
||||
verifierErr: errors.New("invalid signature"),
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
wantErrorCode: service.CodeAuthInvalidToken,
|
||||
wantEvent: service.EventTokenAuthnFail,
|
||||
},
|
||||
{
|
||||
name: "inactive token",
|
||||
path: "/api/v1/supply/packages",
|
||||
authHeader: "Bearer active-token",
|
||||
status: service.TokenStatusRevoked,
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
wantErrorCode: service.CodeAuthTokenInactive,
|
||||
wantEvent: service.EventTokenAuthnFail,
|
||||
},
|
||||
{
|
||||
name: "scope denied",
|
||||
path: "/api/v1/supply/packages",
|
||||
authHeader: "Bearer active-token",
|
||||
status: service.TokenStatusActive,
|
||||
allowed: false,
|
||||
wantStatus: http.StatusForbidden,
|
||||
wantErrorCode: service.CodeAuthScopeDenied,
|
||||
wantEvent: service.EventTokenAuthzDenied,
|
||||
},
|
||||
{
|
||||
name: "authn success",
|
||||
path: "/api/v1/supply/packages",
|
||||
authHeader: "Bearer active-token",
|
||||
status: service.TokenStatusActive,
|
||||
allowed: true,
|
||||
wantStatus: http.StatusNoContent,
|
||||
wantEvent: service.EventTokenAuthnSuccess,
|
||||
wantNext: true,
|
||||
},
|
||||
{
|
||||
name: "excluded path bypasses auth",
|
||||
path: "/healthz",
|
||||
wantStatus: http.StatusNoContent,
|
||||
wantNext: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
auditor := &fakeAuditor{}
|
||||
verifier := &fakeVerifier{
|
||||
token: baseToken,
|
||||
err: tc.verifierErr,
|
||||
}
|
||||
resolver := &fakeStatusResolver{
|
||||
status: tc.status,
|
||||
err: tc.statusErr,
|
||||
}
|
||||
authorizer := &fakeAuthorizer{allowed: tc.allowed}
|
||||
nextCalled := false
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
nextCalled = true
|
||||
if tc.wantNext && strings.HasPrefix(tc.path, "/api/v1/") {
|
||||
principal, ok := PrincipalFromContext(r.Context())
|
||||
if !ok {
|
||||
t.Fatalf("principal should be attached when auth succeeded")
|
||||
}
|
||||
if principal.TokenID != baseToken.TokenID {
|
||||
t.Fatalf("unexpected principal token id: got=%s want=%s", principal.TokenID, baseToken.TokenID)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
handler := TokenAuthMiddleware(AuthMiddlewareConfig{
|
||||
Verifier: verifier,
|
||||
StatusResolver: resolver,
|
||||
Authorizer: authorizer,
|
||||
Auditor: auditor,
|
||||
ProtectedPrefixes: []string{"/api/v1/supply/", "/api/v1/platform/"},
|
||||
ExcludedPrefixes: []string{"/healthz"},
|
||||
Now: fixedNow,
|
||||
})(next)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||
if tc.authHeader != "" {
|
||||
req.Header.Set("Authorization", tc.authHeader)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != tc.wantStatus {
|
||||
t.Fatalf("unexpected status code: got=%d want=%d", rec.Code, tc.wantStatus)
|
||||
}
|
||||
if tc.wantErrorCode != "" {
|
||||
if got := decodeErrorCode(t, rec); got != tc.wantErrorCode {
|
||||
t.Fatalf("unexpected error code: got=%s want=%s", got, tc.wantErrorCode)
|
||||
}
|
||||
}
|
||||
if nextCalled != tc.wantNext {
|
||||
t.Fatalf("unexpected next call state: got=%v want=%v", nextCalled, tc.wantNext)
|
||||
}
|
||||
if tc.wantEvent == "" {
|
||||
return
|
||||
}
|
||||
if len(auditor.events) == 0 {
|
||||
t.Fatalf("audit event should be emitted")
|
||||
}
|
||||
lastEvent := auditor.events[len(auditor.events)-1]
|
||||
if lastEvent.EventName != tc.wantEvent {
|
||||
t.Fatalf("unexpected event name: got=%s want=%s", lastEvent.EventName, tc.wantEvent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type errorEnvelope struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
func decodeErrorCode(t *testing.T, rec *httptest.ResponseRecorder) string {
|
||||
t.Helper()
|
||||
var envelope errorEnvelope
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
return envelope.Error.Code
|
||||
}
|
||||
35
platform-token-runtime/internal/auth/model/principal.go
Normal file
35
platform-token-runtime/internal/auth/model/principal.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package model
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
RoleOwner = "owner"
|
||||
RoleViewer = "viewer"
|
||||
RoleAdmin = "admin"
|
||||
)
|
||||
|
||||
type Principal struct {
|
||||
RequestID string
|
||||
TokenID string
|
||||
SubjectID string
|
||||
Role string
|
||||
Scope []string
|
||||
}
|
||||
|
||||
func (p Principal) HasScope(required string) bool {
|
||||
if required == "" {
|
||||
return true
|
||||
}
|
||||
for _, scope := range p.Scope {
|
||||
if scope == required {
|
||||
return true
|
||||
}
|
||||
if strings.HasSuffix(scope, ":*") {
|
||||
prefix := strings.TrimSuffix(scope, "*")
|
||||
if strings.HasPrefix(required, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
491
platform-token-runtime/internal/auth/service/inmemory_runtime.go
Normal file
491
platform-token-runtime/internal/auth/service/inmemory_runtime.go
Normal file
@@ -0,0 +1,491 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"lijiaoqiao/platform-token-runtime/internal/auth/model"
|
||||
)
|
||||
|
||||
type TokenRecord struct {
|
||||
TokenID string
|
||||
AccessToken string
|
||||
SubjectID string
|
||||
Role string
|
||||
Scope []string
|
||||
IssuedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
Status TokenStatus
|
||||
RequestID string
|
||||
RevokedReason string
|
||||
}
|
||||
|
||||
type IssueTokenInput struct {
|
||||
SubjectID string
|
||||
Role string
|
||||
Scope []string
|
||||
TTL time.Duration
|
||||
RequestID string
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type InMemoryTokenRuntime struct {
|
||||
mu sync.RWMutex
|
||||
now func() time.Time
|
||||
records map[string]*TokenRecord
|
||||
tokenToID map[string]string
|
||||
idempotencyByKey map[string]idempotencyEntry
|
||||
}
|
||||
|
||||
type idempotencyEntry struct {
|
||||
RequestHash string
|
||||
TokenID string
|
||||
}
|
||||
|
||||
func NewInMemoryTokenRuntime(now func() time.Time) *InMemoryTokenRuntime {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &InMemoryTokenRuntime{
|
||||
now: now,
|
||||
records: make(map[string]*TokenRecord),
|
||||
tokenToID: make(map[string]string),
|
||||
idempotencyByKey: make(map[string]idempotencyEntry),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *InMemoryTokenRuntime) Issue(_ context.Context, input IssueTokenInput) (TokenRecord, error) {
|
||||
if strings.TrimSpace(input.SubjectID) == "" {
|
||||
return TokenRecord{}, errors.New("subject_id is required")
|
||||
}
|
||||
if strings.TrimSpace(input.Role) == "" {
|
||||
return TokenRecord{}, errors.New("role is required")
|
||||
}
|
||||
if input.TTL <= 0 {
|
||||
return TokenRecord{}, errors.New("ttl must be positive")
|
||||
}
|
||||
if len(input.Scope) == 0 {
|
||||
return TokenRecord{}, errors.New("scope must not be empty")
|
||||
}
|
||||
idempotencyKey := strings.TrimSpace(input.IdempotencyKey)
|
||||
requestHash := hashIssueInput(input)
|
||||
|
||||
issuedAt := r.now()
|
||||
tokenID, err := generateTokenID()
|
||||
if err != nil {
|
||||
return TokenRecord{}, err
|
||||
}
|
||||
accessToken, err := generateAccessToken()
|
||||
if err != nil {
|
||||
return TokenRecord{}, err
|
||||
}
|
||||
|
||||
record := TokenRecord{
|
||||
TokenID: tokenID,
|
||||
AccessToken: accessToken,
|
||||
SubjectID: input.SubjectID,
|
||||
Role: input.Role,
|
||||
Scope: append([]string(nil), input.Scope...),
|
||||
IssuedAt: issuedAt,
|
||||
ExpiresAt: issuedAt.Add(input.TTL),
|
||||
Status: TokenStatusActive,
|
||||
RequestID: input.RequestID,
|
||||
RevokedReason: "",
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
if idempotencyKey != "" {
|
||||
entry, ok := r.idempotencyByKey[idempotencyKey]
|
||||
if ok {
|
||||
if entry.RequestHash != requestHash {
|
||||
r.mu.Unlock()
|
||||
return TokenRecord{}, errors.New("idempotency key payload mismatch")
|
||||
}
|
||||
existing, exists := r.records[entry.TokenID]
|
||||
if exists {
|
||||
r.mu.Unlock()
|
||||
return cloneRecord(*existing), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
r.records[tokenID] = &record
|
||||
r.tokenToID[accessToken] = tokenID
|
||||
if idempotencyKey != "" {
|
||||
r.idempotencyByKey[idempotencyKey] = idempotencyEntry{
|
||||
RequestHash: requestHash,
|
||||
TokenID: tokenID,
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (r *InMemoryTokenRuntime) Refresh(_ context.Context, tokenID string, ttl time.Duration) (TokenRecord, error) {
|
||||
if ttl <= 0 {
|
||||
return TokenRecord{}, errors.New("ttl must be positive")
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
record, ok := r.records[tokenID]
|
||||
if !ok {
|
||||
return TokenRecord{}, errors.New("token not found")
|
||||
}
|
||||
r.applyExpiry(record)
|
||||
if record.Status != TokenStatusActive {
|
||||
return TokenRecord{}, errors.New("token is not active")
|
||||
}
|
||||
|
||||
record.ExpiresAt = r.now().Add(ttl)
|
||||
return cloneRecord(*record), nil
|
||||
}
|
||||
|
||||
func (r *InMemoryTokenRuntime) Revoke(_ context.Context, tokenID, reason string) (TokenRecord, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
record, ok := r.records[tokenID]
|
||||
if !ok {
|
||||
return TokenRecord{}, errors.New("token not found")
|
||||
}
|
||||
r.applyExpiry(record)
|
||||
record.Status = TokenStatusRevoked
|
||||
record.RevokedReason = strings.TrimSpace(reason)
|
||||
return cloneRecord(*record), nil
|
||||
}
|
||||
|
||||
func (r *InMemoryTokenRuntime) Introspect(_ context.Context, accessToken string) (TokenRecord, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
tokenID, ok := r.tokenToID[accessToken]
|
||||
if !ok {
|
||||
return TokenRecord{}, errors.New("token not found")
|
||||
}
|
||||
record := r.records[tokenID]
|
||||
r.applyExpiry(record)
|
||||
return cloneRecord(*record), nil
|
||||
}
|
||||
|
||||
func (r *InMemoryTokenRuntime) Lookup(_ context.Context, tokenID string) (TokenRecord, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
record, ok := r.records[tokenID]
|
||||
if !ok {
|
||||
return TokenRecord{}, errors.New("token not found")
|
||||
}
|
||||
r.applyExpiry(record)
|
||||
return cloneRecord(*record), nil
|
||||
}
|
||||
|
||||
func (r *InMemoryTokenRuntime) Verify(_ context.Context, rawToken string) (VerifiedToken, error) {
|
||||
r.mu.RLock()
|
||||
tokenID, ok := r.tokenToID[rawToken]
|
||||
if !ok {
|
||||
r.mu.RUnlock()
|
||||
return VerifiedToken{}, NewAuthError(CodeAuthInvalidToken, errors.New("token not found"))
|
||||
}
|
||||
record, ok := r.records[tokenID]
|
||||
if !ok {
|
||||
r.mu.RUnlock()
|
||||
return VerifiedToken{}, NewAuthError(CodeAuthInvalidToken, errors.New("token record not found"))
|
||||
}
|
||||
claims := VerifiedToken{
|
||||
TokenID: record.TokenID,
|
||||
SubjectID: record.SubjectID,
|
||||
Role: record.Role,
|
||||
Scope: append([]string(nil), record.Scope...),
|
||||
IssuedAt: record.IssuedAt,
|
||||
ExpiresAt: record.ExpiresAt,
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (r *InMemoryTokenRuntime) Resolve(_ context.Context, tokenID string) (TokenStatus, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
record, ok := r.records[tokenID]
|
||||
if !ok {
|
||||
return "", NewAuthError(CodeAuthInvalidToken, errors.New("token not found"))
|
||||
}
|
||||
r.applyExpiry(record)
|
||||
return record.Status, nil
|
||||
}
|
||||
|
||||
func (r *InMemoryTokenRuntime) TokenCount() int {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return len(r.records)
|
||||
}
|
||||
|
||||
func (r *InMemoryTokenRuntime) IssueAndAudit(ctx context.Context, input IssueTokenInput, auditor AuditEmitter) (TokenRecord, error) {
|
||||
record, err := r.Issue(ctx, input)
|
||||
if err != nil {
|
||||
emitAudit(auditor, AuditEvent{
|
||||
EventName: EventTokenIssueFail,
|
||||
RequestID: input.RequestID,
|
||||
SubjectID: input.SubjectID,
|
||||
Route: "/api/v1/platform/tokens/issue",
|
||||
ResultCode: "ISSUE_FAILED",
|
||||
}, r.now)
|
||||
return TokenRecord{}, err
|
||||
}
|
||||
emitAudit(auditor, AuditEvent{
|
||||
EventName: EventTokenIssueSuccess,
|
||||
RequestID: input.RequestID,
|
||||
TokenID: record.TokenID,
|
||||
SubjectID: record.SubjectID,
|
||||
Route: "/api/v1/platform/tokens/issue",
|
||||
ResultCode: "OK",
|
||||
}, r.now)
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (r *InMemoryTokenRuntime) RevokeAndAudit(ctx context.Context, tokenID, reason, requestID, subjectID string, auditor AuditEmitter) (TokenRecord, error) {
|
||||
record, err := r.Revoke(ctx, tokenID, reason)
|
||||
if err != nil {
|
||||
emitAudit(auditor, AuditEvent{
|
||||
EventName: EventTokenRevokeFail,
|
||||
RequestID: requestID,
|
||||
TokenID: tokenID,
|
||||
SubjectID: subjectID,
|
||||
Route: "/api/v1/platform/tokens/revoke",
|
||||
ResultCode: "REVOKE_FAILED",
|
||||
}, r.now)
|
||||
return TokenRecord{}, err
|
||||
}
|
||||
emitAudit(auditor, AuditEvent{
|
||||
EventName: EventTokenRevokeSuccess,
|
||||
RequestID: requestID,
|
||||
TokenID: record.TokenID,
|
||||
SubjectID: record.SubjectID,
|
||||
Route: "/api/v1/platform/tokens/revoke",
|
||||
ResultCode: "OK",
|
||||
}, r.now)
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (r *InMemoryTokenRuntime) applyExpiry(record *TokenRecord) {
|
||||
if record == nil {
|
||||
return
|
||||
}
|
||||
if record.Status == TokenStatusActive && !record.ExpiresAt.IsZero() && !r.now().Before(record.ExpiresAt) {
|
||||
record.Status = TokenStatusExpired
|
||||
}
|
||||
}
|
||||
|
||||
func cloneRecord(record TokenRecord) TokenRecord {
|
||||
record.Scope = append([]string(nil), record.Scope...)
|
||||
return record
|
||||
}
|
||||
|
||||
func hashIssueInput(input IssueTokenInput) string {
|
||||
scope := append([]string(nil), input.Scope...)
|
||||
sort.Strings(scope)
|
||||
joined := strings.Join(scope, ",")
|
||||
data := strings.TrimSpace(input.SubjectID) + "|" +
|
||||
strings.TrimSpace(input.Role) + "|" +
|
||||
joined + "|" +
|
||||
input.TTL.String()
|
||||
sum := sha256.Sum256([]byte(data))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func generateAccessToken() (string, error) {
|
||||
var entropy [16]byte
|
||||
if _, err := rand.Read(entropy[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "ptk_" + hex.EncodeToString(entropy[:]), nil
|
||||
}
|
||||
|
||||
func generateTokenID() (string, error) {
|
||||
var entropy [8]byte
|
||||
if _, err := rand.Read(entropy[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "tok_" + hex.EncodeToString(entropy[:]), nil
|
||||
}
|
||||
|
||||
type ScopeRoleAuthorizer struct{}
|
||||
|
||||
func NewScopeRoleAuthorizer() *ScopeRoleAuthorizer {
|
||||
return &ScopeRoleAuthorizer{}
|
||||
}
|
||||
|
||||
func (a *ScopeRoleAuthorizer) Authorize(path, method string, scopes []string, role string) bool {
|
||||
if role == model.RoleAdmin {
|
||||
return true
|
||||
}
|
||||
|
||||
requiredScope := requiredScopeForRoute(path, method)
|
||||
if requiredScope == "" {
|
||||
return true
|
||||
}
|
||||
return hasScope(scopes, requiredScope)
|
||||
}
|
||||
|
||||
func requiredScopeForRoute(path, method string) string {
|
||||
if path == "/api/v1/supply" || strings.HasPrefix(path, "/api/v1/supply/") {
|
||||
switch method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
return "supply:read"
|
||||
default:
|
||||
return "supply:write"
|
||||
}
|
||||
}
|
||||
if path == "/api/v1/platform" || strings.HasPrefix(path, "/api/v1/platform/") {
|
||||
return "platform:admin"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func hasScope(scopes []string, required string) bool {
|
||||
for _, scope := range scopes {
|
||||
if scope == required {
|
||||
return true
|
||||
}
|
||||
if strings.HasSuffix(scope, ":*") {
|
||||
prefix := strings.TrimSuffix(scope, "*")
|
||||
if strings.HasPrefix(required, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type MemoryAuditEmitter struct {
|
||||
mu sync.RWMutex
|
||||
events []AuditEvent
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewMemoryAuditEmitter() *MemoryAuditEmitter {
|
||||
return &MemoryAuditEmitter{now: time.Now}
|
||||
}
|
||||
|
||||
func (e *MemoryAuditEmitter) Emit(_ context.Context, event AuditEvent) error {
|
||||
if event.EventID == "" {
|
||||
eventID, err := generateEventID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
event.EventID = eventID
|
||||
}
|
||||
if event.CreatedAt.IsZero() {
|
||||
event.CreatedAt = e.now()
|
||||
}
|
||||
e.mu.Lock()
|
||||
e.events = append(e.events, event)
|
||||
e.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *MemoryAuditEmitter) Events() []AuditEvent {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
copied := make([]AuditEvent, len(e.events))
|
||||
copy(copied, e.events)
|
||||
return copied
|
||||
}
|
||||
|
||||
func (e *MemoryAuditEmitter) QueryEvents(_ context.Context, filter AuditEventFilter) ([]AuditEvent, error) {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
|
||||
limit := filter.Limit
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
if limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
|
||||
result := make([]AuditEvent, 0, minInt(limit, len(e.events)))
|
||||
for idx := len(e.events) - 1; idx >= 0; idx-- {
|
||||
ev := e.events[idx]
|
||||
if !matchAuditFilter(ev, filter) {
|
||||
continue
|
||||
}
|
||||
result = append(result, ev)
|
||||
if len(result) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 按时间正序返回,便于前端/审计系统展示时间线。
|
||||
for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
|
||||
result[i], result[j] = result[j], result[i]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (e *MemoryAuditEmitter) LastEvent() (AuditEvent, bool) {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
if len(e.events) == 0 {
|
||||
return AuditEvent{}, false
|
||||
}
|
||||
return e.events[len(e.events)-1], true
|
||||
}
|
||||
|
||||
func emitAudit(emitter AuditEmitter, event AuditEvent, now func() time.Time) {
|
||||
if emitter == nil {
|
||||
return
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
if event.CreatedAt.IsZero() {
|
||||
event.CreatedAt = now()
|
||||
}
|
||||
_ = emitter.Emit(context.Background(), event)
|
||||
}
|
||||
|
||||
func matchAuditFilter(ev AuditEvent, filter AuditEventFilter) bool {
|
||||
if filter.RequestID != "" && ev.RequestID != filter.RequestID {
|
||||
return false
|
||||
}
|
||||
if filter.TokenID != "" && ev.TokenID != filter.TokenID {
|
||||
return false
|
||||
}
|
||||
if filter.SubjectID != "" && ev.SubjectID != filter.SubjectID {
|
||||
return false
|
||||
}
|
||||
if filter.EventName != "" && ev.EventName != filter.EventName {
|
||||
return false
|
||||
}
|
||||
if filter.ResultCode != "" && ev.ResultCode != filter.ResultCode {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func generateEventID() (string, error) {
|
||||
var entropy [8]byte
|
||||
if _, err := rand.Read(entropy[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "evt_" + hex.EncodeToString(entropy[:]), nil
|
||||
}
|
||||
127
platform-token-runtime/internal/auth/service/token_verifier.go
Normal file
127
platform-token-runtime/internal/auth/service/token_verifier.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
CodeAuthMissingBearer = "AUTH_MISSING_BEARER"
|
||||
CodeQueryKeyNotAllowed = "QUERY_KEY_NOT_ALLOWED"
|
||||
CodeAuthInvalidToken = "AUTH_INVALID_TOKEN"
|
||||
CodeAuthTokenInactive = "AUTH_TOKEN_INACTIVE"
|
||||
CodeAuthScopeDenied = "AUTH_SCOPE_DENIED"
|
||||
CodeAuthNotReady = "AUTH_NOT_READY"
|
||||
)
|
||||
|
||||
const (
|
||||
EventTokenAuthnSuccess = "token.authn.success"
|
||||
EventTokenAuthnFail = "token.authn.fail"
|
||||
EventTokenAuthzDenied = "token.authz.denied"
|
||||
EventTokenQueryKeyRejected = "token.query_key.rejected"
|
||||
EventTokenIssueSuccess = "token.issue.success"
|
||||
EventTokenIssueFail = "token.issue.fail"
|
||||
EventTokenIntrospectSuccess = "token.introspect.success"
|
||||
EventTokenIntrospectFail = "token.introspect.fail"
|
||||
EventTokenRefreshSuccess = "token.refresh.success"
|
||||
EventTokenRefreshFail = "token.refresh.fail"
|
||||
EventTokenRevokeSuccess = "token.revoke.success"
|
||||
EventTokenRevokeFail = "token.revoke.fail"
|
||||
)
|
||||
|
||||
type TokenStatus string
|
||||
|
||||
const (
|
||||
TokenStatusActive TokenStatus = "active"
|
||||
TokenStatusRevoked TokenStatus = "revoked"
|
||||
TokenStatusExpired TokenStatus = "expired"
|
||||
)
|
||||
|
||||
type VerifiedToken struct {
|
||||
TokenID string
|
||||
SubjectID string
|
||||
Role string
|
||||
Scope []string
|
||||
IssuedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
NotBefore time.Time
|
||||
Issuer string
|
||||
Audience string
|
||||
}
|
||||
|
||||
type TokenVerifier interface {
|
||||
Verify(ctx context.Context, rawToken string) (VerifiedToken, error)
|
||||
}
|
||||
|
||||
type TokenStatusResolver interface {
|
||||
Resolve(ctx context.Context, tokenID string) (TokenStatus, error)
|
||||
}
|
||||
|
||||
type RouteAuthorizer interface {
|
||||
Authorize(path, method string, scopes []string, role string) bool
|
||||
}
|
||||
|
||||
type AuditEvent struct {
|
||||
EventID string
|
||||
EventName string
|
||||
RequestID string
|
||||
TokenID string
|
||||
SubjectID string
|
||||
Route string
|
||||
ResultCode string
|
||||
ClientIP string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type AuditEmitter interface {
|
||||
Emit(ctx context.Context, event AuditEvent) error
|
||||
}
|
||||
|
||||
type AuditEventFilter struct {
|
||||
RequestID string
|
||||
TokenID string
|
||||
SubjectID string
|
||||
EventName string
|
||||
ResultCode string
|
||||
Limit int
|
||||
}
|
||||
|
||||
type AuditEventQuerier interface {
|
||||
QueryEvents(ctx context.Context, filter AuditEventFilter) ([]AuditEvent, error)
|
||||
}
|
||||
|
||||
type AuthError struct {
|
||||
Code string
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e *AuthError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
if e.Cause == nil {
|
||||
return e.Code
|
||||
}
|
||||
return fmt.Sprintf("%s: %v", e.Code, e.Cause)
|
||||
}
|
||||
|
||||
func (e *AuthError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
func NewAuthError(code string, cause error) *AuthError {
|
||||
return &AuthError{Code: code, Cause: cause}
|
||||
}
|
||||
|
||||
func IsAuthCode(err error, code string) bool {
|
||||
var authErr *AuthError
|
||||
if !errors.As(err, &authErr) {
|
||||
return false
|
||||
}
|
||||
return authErr.Code == code
|
||||
}
|
||||
437
platform-token-runtime/internal/httpapi/token_api.go
Normal file
437
platform-token-runtime/internal/httpapi/token_api.go
Normal file
@@ -0,0 +1,437 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lijiaoqiao/platform-token-runtime/internal/auth/model"
|
||||
"lijiaoqiao/platform-token-runtime/internal/auth/service"
|
||||
)
|
||||
|
||||
const (
|
||||
tokenBasePath = "/api/v1/platform/tokens"
|
||||
)
|
||||
|
||||
type Runtime interface {
|
||||
IssueAndAudit(ctx context.Context, input service.IssueTokenInput, auditor service.AuditEmitter) (service.TokenRecord, error)
|
||||
Refresh(ctx context.Context, tokenID string, ttl time.Duration) (service.TokenRecord, error)
|
||||
RevokeAndAudit(ctx context.Context, tokenID, reason, requestID, subjectID string, auditor service.AuditEmitter) (service.TokenRecord, error)
|
||||
Introspect(ctx context.Context, accessToken string) (service.TokenRecord, error)
|
||||
Lookup(ctx context.Context, tokenID string) (service.TokenRecord, error)
|
||||
}
|
||||
|
||||
type TokenAPI struct {
|
||||
runtime Runtime
|
||||
auditor service.AuditEmitter
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewTokenAPI(runtime Runtime, auditor service.AuditEmitter, now func() time.Time) *TokenAPI {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &TokenAPI{runtime: runtime, auditor: auditor, now: now}
|
||||
}
|
||||
|
||||
func (a *TokenAPI) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc(tokenBasePath+"/issue", a.handleIssue)
|
||||
mux.HandleFunc(tokenBasePath+"/introspect", a.handleIntrospect)
|
||||
mux.HandleFunc(tokenBasePath+"/audit-events", a.handleAuditEvents)
|
||||
mux.HandleFunc(tokenBasePath+"/", a.handleTokenAction)
|
||||
}
|
||||
|
||||
func (a *TokenAPI) handleTokenAction(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.URL.Path, tokenBasePath+"/") {
|
||||
writeError(w, http.StatusNotFound, "NOT_FOUND", "route not found")
|
||||
return
|
||||
}
|
||||
tail := strings.TrimPrefix(r.URL.Path, tokenBasePath+"/")
|
||||
parts := strings.Split(tail, "/")
|
||||
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" {
|
||||
writeError(w, http.StatusNotFound, "NOT_FOUND", "route not found")
|
||||
return
|
||||
}
|
||||
tokenID := strings.TrimSpace(parts[0])
|
||||
action := strings.TrimSpace(parts[1])
|
||||
|
||||
switch action {
|
||||
case "refresh":
|
||||
a.handleRefresh(w, r, tokenID)
|
||||
case "revoke":
|
||||
a.handleRevoke(w, r, tokenID)
|
||||
default:
|
||||
writeError(w, http.StatusNotFound, "NOT_FOUND", "route not found")
|
||||
}
|
||||
}
|
||||
|
||||
type issueRequest struct {
|
||||
SubjectID string `json:"subject_id"`
|
||||
Role string `json:"role"`
|
||||
TTLSeconds int64 `json:"ttl_seconds"`
|
||||
Scope []string `json:"scope"`
|
||||
}
|
||||
|
||||
type refreshRequest struct {
|
||||
TTLSeconds int64 `json:"ttl_seconds"`
|
||||
}
|
||||
|
||||
type revokeRequest struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type introspectRequest struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type errorEnvelope struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
func (a *TokenAPI) handleIssue(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
|
||||
return
|
||||
}
|
||||
requestID := strings.TrimSpace(r.Header.Get("X-Request-Id"))
|
||||
idempotencyKey := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
|
||||
if requestID == "" || idempotencyKey == "" {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "missing X-Request-Id or Idempotency-Key")
|
||||
return
|
||||
}
|
||||
|
||||
var req issueRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
if err := validateIssueRequest(req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
record, err := a.runtime.IssueAndAudit(r.Context(), service.IssueTokenInput{
|
||||
SubjectID: req.SubjectID,
|
||||
Role: req.Role,
|
||||
Scope: req.Scope,
|
||||
TTL: time.Duration(req.TTLSeconds) * time.Second,
|
||||
RequestID: requestID,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
}, a.auditor)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "idempotency key payload mismatch") {
|
||||
writeError(w, http.StatusConflict, "IDEMPOTENCY_CONFLICT", "idempotency key payload mismatch")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusUnprocessableEntity, "ISSUE_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"request_id": requestID,
|
||||
"data": map[string]any{
|
||||
"token_id": record.TokenID,
|
||||
"access_token": record.AccessToken,
|
||||
"issued_at": record.IssuedAt,
|
||||
"expires_at": record.ExpiresAt,
|
||||
"status": record.Status,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *TokenAPI) handleRefresh(w http.ResponseWriter, r *http.Request, tokenID string) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
|
||||
return
|
||||
}
|
||||
requestID := strings.TrimSpace(r.Header.Get("X-Request-Id"))
|
||||
idempotencyKey := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
|
||||
if requestID == "" || idempotencyKey == "" {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "missing X-Request-Id or Idempotency-Key")
|
||||
return
|
||||
}
|
||||
|
||||
var req refreshRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
if req.TTLSeconds < 60 {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "ttl_seconds must be >= 60")
|
||||
return
|
||||
}
|
||||
|
||||
before, err := a.runtime.Lookup(r.Context(), tokenID)
|
||||
if err != nil {
|
||||
before = service.TokenRecord{}
|
||||
}
|
||||
|
||||
record, err := a.runtime.Refresh(r.Context(), tokenID, time.Duration(req.TTLSeconds)*time.Second)
|
||||
if err != nil {
|
||||
status, code := mapRuntimeError(err)
|
||||
writeError(w, status, code, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if a.auditor != nil {
|
||||
_ = a.auditor.Emit(r.Context(), service.AuditEvent{
|
||||
EventName: service.EventTokenRefreshSuccess,
|
||||
RequestID: requestID,
|
||||
TokenID: record.TokenID,
|
||||
SubjectID: record.SubjectID,
|
||||
Route: tokenBasePath + "/" + tokenID + "/refresh",
|
||||
ResultCode: "OK",
|
||||
CreatedAt: a.now(),
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"request_id": requestID,
|
||||
"data": map[string]any{
|
||||
"token_id": record.TokenID,
|
||||
"previous_expires_at": before.ExpiresAt,
|
||||
"expires_at": record.ExpiresAt,
|
||||
"status": record.Status,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *TokenAPI) handleRevoke(w http.ResponseWriter, r *http.Request, tokenID string) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
|
||||
return
|
||||
}
|
||||
requestID := strings.TrimSpace(r.Header.Get("X-Request-Id"))
|
||||
idempotencyKey := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
|
||||
if requestID == "" || idempotencyKey == "" {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "missing X-Request-Id or Idempotency-Key")
|
||||
return
|
||||
}
|
||||
|
||||
var req revokeRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Reason) == "" {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "reason is required")
|
||||
return
|
||||
}
|
||||
|
||||
introspected, err := a.runtime.Lookup(r.Context(), tokenID)
|
||||
subjectID := ""
|
||||
if err == nil {
|
||||
subjectID = introspected.SubjectID
|
||||
}
|
||||
|
||||
record, err := a.runtime.RevokeAndAudit(r.Context(), tokenID, req.Reason, requestID, subjectID, a.auditor)
|
||||
if err != nil {
|
||||
status, code := mapRuntimeError(err)
|
||||
writeError(w, status, code, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"request_id": requestID,
|
||||
"data": map[string]any{
|
||||
"token_id": record.TokenID,
|
||||
"status": record.Status,
|
||||
"revoked_at": a.now(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *TokenAPI) handleIntrospect(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
|
||||
return
|
||||
}
|
||||
requestID := strings.TrimSpace(r.Header.Get("X-Request-Id"))
|
||||
if requestID == "" {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "missing X-Request-Id")
|
||||
return
|
||||
}
|
||||
|
||||
var req introspectRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Token) == "" {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "token is required")
|
||||
return
|
||||
}
|
||||
|
||||
record, err := a.runtime.Introspect(r.Context(), req.Token)
|
||||
if err != nil {
|
||||
if a.auditor != nil {
|
||||
_ = a.auditor.Emit(r.Context(), service.AuditEvent{
|
||||
EventName: service.EventTokenIntrospectFail,
|
||||
RequestID: requestID,
|
||||
Route: tokenBasePath + "/introspect",
|
||||
ResultCode: "INVALID_TOKEN",
|
||||
CreatedAt: a.now(),
|
||||
})
|
||||
}
|
||||
writeError(w, http.StatusUnprocessableEntity, "TOKEN_INVALID", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if a.auditor != nil {
|
||||
_ = a.auditor.Emit(r.Context(), service.AuditEvent{
|
||||
EventName: service.EventTokenIntrospectSuccess,
|
||||
RequestID: requestID,
|
||||
TokenID: record.TokenID,
|
||||
SubjectID: record.SubjectID,
|
||||
Route: tokenBasePath + "/introspect",
|
||||
ResultCode: "OK",
|
||||
CreatedAt: a.now(),
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"request_id": requestID,
|
||||
"data": map[string]any{
|
||||
"token_id": record.TokenID,
|
||||
"subject_id": record.SubjectID,
|
||||
"role": record.Role,
|
||||
"status": record.Status,
|
||||
"scope": record.Scope,
|
||||
"issued_at": record.IssuedAt,
|
||||
"expires_at": record.ExpiresAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *TokenAPI) handleAuditEvents(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
|
||||
return
|
||||
}
|
||||
requestID := strings.TrimSpace(r.Header.Get("X-Request-Id"))
|
||||
if requestID == "" {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "missing X-Request-Id")
|
||||
return
|
||||
}
|
||||
|
||||
querier, ok := a.auditor.(service.AuditEventQuerier)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotImplemented, "AUDIT_QUERY_NOT_READY", "audit query capability is not available")
|
||||
return
|
||||
}
|
||||
|
||||
limit := parseLimit(r.URL.Query().Get("limit"))
|
||||
filter := service.AuditEventFilter{
|
||||
RequestID: strings.TrimSpace(r.URL.Query().Get("request_id")),
|
||||
TokenID: strings.TrimSpace(r.URL.Query().Get("token_id")),
|
||||
SubjectID: strings.TrimSpace(r.URL.Query().Get("subject_id")),
|
||||
EventName: strings.TrimSpace(r.URL.Query().Get("event_name")),
|
||||
ResultCode: strings.TrimSpace(r.URL.Query().Get("result_code")),
|
||||
Limit: limit,
|
||||
}
|
||||
events, err := querier.QueryEvents(r.Context(), filter)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "AUDIT_QUERY_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]map[string]any, 0, len(events))
|
||||
for _, ev := range events {
|
||||
items = append(items, map[string]any{
|
||||
"event_id": ev.EventID,
|
||||
"event_name": ev.EventName,
|
||||
"request_id": ev.RequestID,
|
||||
"token_id": ev.TokenID,
|
||||
"subject_id": ev.SubjectID,
|
||||
"route": ev.Route,
|
||||
"result_code": ev.ResultCode,
|
||||
"client_ip": ev.ClientIP,
|
||||
"created_at": ev.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"request_id": requestID,
|
||||
"data": map[string]any{
|
||||
"total": len(items),
|
||||
"items": items,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func validateIssueRequest(req issueRequest) error {
|
||||
if strings.TrimSpace(req.SubjectID) == "" {
|
||||
return errors.New("subject_id is required")
|
||||
}
|
||||
if req.TTLSeconds < 60 {
|
||||
return errors.New("ttl_seconds must be >= 60")
|
||||
}
|
||||
if len(req.Scope) == 0 {
|
||||
return errors.New("scope is required")
|
||||
}
|
||||
switch req.Role {
|
||||
case model.RoleOwner, model.RoleViewer, model.RoleAdmin:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unsupported role: %s", req.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func mapRuntimeError(err error) (int, string) {
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case strings.Contains(msg, "not found"):
|
||||
return http.StatusNotFound, "TOKEN_NOT_FOUND"
|
||||
case strings.Contains(msg, "not active"):
|
||||
return http.StatusConflict, "TOKEN_NOT_ACTIVE"
|
||||
case strings.Contains(msg, "idempotency key payload mismatch"):
|
||||
return http.StatusConflict, "IDEMPOTENCY_CONFLICT"
|
||||
default:
|
||||
return http.StatusUnprocessableEntity, "BUSINESS_ERROR"
|
||||
}
|
||||
}
|
||||
|
||||
func decodeJSON(r *http.Request, out any) error {
|
||||
defer r.Body.Close()
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(out); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, code, message string) {
|
||||
var env errorEnvelope
|
||||
env.Error.Code = code
|
||||
env.Error.Message = message
|
||||
writeJSON(w, status, env)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
func parseLimit(raw string) int {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return 100
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil || n <= 0 {
|
||||
return 100
|
||||
}
|
||||
if n > 500 {
|
||||
return 500
|
||||
}
|
||||
return n
|
||||
}
|
||||
269
platform-token-runtime/internal/httpapi/token_api_test.go
Normal file
269
platform-token-runtime/internal/httpapi/token_api_test.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lijiaoqiao/platform-token-runtime/internal/auth/service"
|
||||
)
|
||||
|
||||
func TestTokenAPIIssueAndIntrospect(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := service.NewInMemoryTokenRuntime(nil)
|
||||
auditor := service.NewMemoryAuditEmitter()
|
||||
api := NewTokenAPI(runtime, auditor, func() time.Time {
|
||||
return time.Date(2026, 3, 30, 15, 50, 0, 0, time.UTC)
|
||||
})
|
||||
mux := http.NewServeMux()
|
||||
api.Register(mux)
|
||||
|
||||
issueBody := map[string]any{
|
||||
"subject_id": "2001",
|
||||
"role": "owner",
|
||||
"ttl_seconds": 600,
|
||||
"scope": []string{"supply:*"},
|
||||
}
|
||||
issueReq := httptest.NewRequest(http.MethodPost, "/api/v1/platform/tokens/issue", mustJSON(t, issueBody))
|
||||
issueReq.Header.Set("X-Request-Id", "req-api-001")
|
||||
issueReq.Header.Set("Idempotency-Key", "idem-api-001")
|
||||
issueRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(issueRec, issueReq)
|
||||
|
||||
if issueRec.Code != http.StatusCreated {
|
||||
t.Fatalf("unexpected issue status: got=%d want=%d body=%s", issueRec.Code, http.StatusCreated, issueRec.Body.String())
|
||||
}
|
||||
issueResp := decodeMap(t, issueRec.Body.Bytes())
|
||||
data := issueResp["data"].(map[string]any)
|
||||
accessToken := data["access_token"].(string)
|
||||
if accessToken == "" {
|
||||
t.Fatalf("access_token should not be empty")
|
||||
}
|
||||
|
||||
introspectBody := map[string]any{"token": accessToken}
|
||||
introReq := httptest.NewRequest(http.MethodPost, "/api/v1/platform/tokens/introspect", mustJSON(t, introspectBody))
|
||||
introReq.Header.Set("X-Request-Id", "req-api-002")
|
||||
introRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(introRec, introReq)
|
||||
|
||||
if introRec.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected introspect status: got=%d want=%d body=%s", introRec.Code, http.StatusOK, introRec.Body.String())
|
||||
}
|
||||
introResp := decodeMap(t, introRec.Body.Bytes())
|
||||
introData := introResp["data"].(map[string]any)
|
||||
if introData["role"].(string) != "owner" {
|
||||
t.Fatalf("unexpected role: got=%s want=owner", introData["role"].(string))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenAPIIssueIdempotencyConflict(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := service.NewInMemoryTokenRuntime(nil)
|
||||
api := NewTokenAPI(runtime, service.NewMemoryAuditEmitter(), time.Now)
|
||||
mux := http.NewServeMux()
|
||||
api.Register(mux)
|
||||
|
||||
firstBody := map[string]any{
|
||||
"subject_id": "2001",
|
||||
"role": "owner",
|
||||
"ttl_seconds": 600,
|
||||
"scope": []string{"supply:*"},
|
||||
}
|
||||
secondBody := map[string]any{
|
||||
"subject_id": "2001",
|
||||
"role": "owner",
|
||||
"ttl_seconds": 600,
|
||||
"scope": []string{"supply:read"},
|
||||
}
|
||||
|
||||
firstReq := httptest.NewRequest(http.MethodPost, "/api/v1/platform/tokens/issue", mustJSON(t, firstBody))
|
||||
firstReq.Header.Set("X-Request-Id", "req-api-003-1")
|
||||
firstReq.Header.Set("Idempotency-Key", "idem-api-003")
|
||||
firstRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(firstRec, firstReq)
|
||||
if firstRec.Code != http.StatusCreated {
|
||||
t.Fatalf("first issue should succeed: code=%d body=%s", firstRec.Code, firstRec.Body.String())
|
||||
}
|
||||
|
||||
secondReq := httptest.NewRequest(http.MethodPost, "/api/v1/platform/tokens/issue", mustJSON(t, secondBody))
|
||||
secondReq.Header.Set("X-Request-Id", "req-api-003-2")
|
||||
secondReq.Header.Set("Idempotency-Key", "idem-api-003")
|
||||
secondRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(secondRec, secondReq)
|
||||
if secondRec.Code != http.StatusConflict {
|
||||
t.Fatalf("expected idempotency conflict: code=%d body=%s", secondRec.Code, secondRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenAPIRefreshAndRevoke(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 3, 30, 16, 0, 0, 0, time.UTC)
|
||||
runtime := service.NewInMemoryTokenRuntime(func() time.Time { return now })
|
||||
api := NewTokenAPI(runtime, service.NewMemoryAuditEmitter(), func() time.Time { return now })
|
||||
mux := http.NewServeMux()
|
||||
api.Register(mux)
|
||||
|
||||
issueReq := httptest.NewRequest(http.MethodPost, "/api/v1/platform/tokens/issue", mustJSON(t, map[string]any{
|
||||
"subject_id": "2008",
|
||||
"role": "owner",
|
||||
"ttl_seconds": 120,
|
||||
"scope": []string{"supply:*"},
|
||||
}))
|
||||
issueReq.Header.Set("X-Request-Id", "req-api-004-1")
|
||||
issueReq.Header.Set("Idempotency-Key", "idem-api-004")
|
||||
issueRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(issueRec, issueReq)
|
||||
if issueRec.Code != http.StatusCreated {
|
||||
t.Fatalf("issue failed: code=%d body=%s", issueRec.Code, issueRec.Body.String())
|
||||
}
|
||||
issued := decodeMap(t, issueRec.Body.Bytes())
|
||||
issuedData := issued["data"].(map[string]any)
|
||||
tokenID := issuedData["token_id"].(string)
|
||||
|
||||
now = now.Add(10 * time.Second)
|
||||
refreshReq := httptest.NewRequest(http.MethodPost, "/api/v1/platform/tokens/"+tokenID+"/refresh", mustJSON(t, map[string]any{"ttl_seconds": 300}))
|
||||
refreshReq.Header.Set("X-Request-Id", "req-api-004-2")
|
||||
refreshReq.Header.Set("Idempotency-Key", "idem-api-004-r")
|
||||
refreshRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(refreshRec, refreshReq)
|
||||
if refreshRec.Code != http.StatusOK {
|
||||
t.Fatalf("refresh failed: code=%d body=%s", refreshRec.Code, refreshRec.Body.String())
|
||||
}
|
||||
refreshResp := decodeMap(t, refreshRec.Body.Bytes())
|
||||
refreshData := refreshResp["data"].(map[string]any)
|
||||
if refreshData["previous_expires_at"] == nil {
|
||||
t.Fatalf("previous_expires_at must not be nil")
|
||||
}
|
||||
|
||||
revokeReq := httptest.NewRequest(http.MethodPost, "/api/v1/platform/tokens/"+tokenID+"/revoke", mustJSON(t, map[string]any{"reason": "operator_request"}))
|
||||
revokeReq.Header.Set("X-Request-Id", "req-api-004-3")
|
||||
revokeReq.Header.Set("Idempotency-Key", "idem-api-004-v")
|
||||
revokeRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(revokeRec, revokeReq)
|
||||
if revokeRec.Code != http.StatusOK {
|
||||
t.Fatalf("revoke failed: code=%d body=%s", revokeRec.Code, revokeRec.Body.String())
|
||||
}
|
||||
revokeResp := decodeMap(t, revokeRec.Body.Bytes())
|
||||
revokeData := revokeResp["data"].(map[string]any)
|
||||
if revokeData["status"].(string) != "revoked" {
|
||||
t.Fatalf("unexpected status after revoke: got=%s", revokeData["status"].(string))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenAPIMissingHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := service.NewInMemoryTokenRuntime(nil)
|
||||
api := NewTokenAPI(runtime, service.NewMemoryAuditEmitter(), time.Now)
|
||||
mux := http.NewServeMux()
|
||||
api.Register(mux)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/platform/tokens/issue", mustJSON(t, map[string]any{
|
||||
"subject_id": "2001",
|
||||
"role": "owner",
|
||||
"ttl_seconds": 120,
|
||||
"scope": []string{"supply:*"},
|
||||
}))
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing headers must be rejected: code=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenAPIAuditEventsQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := service.NewInMemoryTokenRuntime(nil)
|
||||
auditor := service.NewMemoryAuditEmitter()
|
||||
api := NewTokenAPI(runtime, auditor, time.Now)
|
||||
mux := http.NewServeMux()
|
||||
api.Register(mux)
|
||||
|
||||
issueReq := httptest.NewRequest(http.MethodPost, "/api/v1/platform/tokens/issue", mustJSON(t, map[string]any{
|
||||
"subject_id": "2010",
|
||||
"role": "owner",
|
||||
"ttl_seconds": 300,
|
||||
"scope": []string{"supply:*"},
|
||||
}))
|
||||
issueReq.Header.Set("X-Request-Id", "req-audit-query-1")
|
||||
issueReq.Header.Set("Idempotency-Key", "idem-audit-query-1")
|
||||
issueRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(issueRec, issueReq)
|
||||
if issueRec.Code != http.StatusCreated {
|
||||
t.Fatalf("issue failed: code=%d body=%s", issueRec.Code, issueRec.Body.String())
|
||||
}
|
||||
issueResp := decodeMap(t, issueRec.Body.Bytes())
|
||||
tokenID := issueResp["data"].(map[string]any)["token_id"].(string)
|
||||
|
||||
queryReq := httptest.NewRequest(http.MethodGet, "/api/v1/platform/tokens/audit-events?token_id="+tokenID+"&limit=5", nil)
|
||||
queryReq.Header.Set("X-Request-Id", "req-audit-query-2")
|
||||
queryRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(queryRec, queryReq)
|
||||
if queryRec.Code != http.StatusOK {
|
||||
t.Fatalf("audit query failed: code=%d body=%s", queryRec.Code, queryRec.Body.String())
|
||||
}
|
||||
resp := decodeMap(t, queryRec.Body.Bytes())
|
||||
data := resp["data"].(map[string]any)
|
||||
items := data["items"].([]any)
|
||||
if len(items) == 0 {
|
||||
t.Fatalf("audit query should return at least one event")
|
||||
}
|
||||
first := items[0].(map[string]any)
|
||||
if first["token_id"].(string) != tokenID {
|
||||
t.Fatalf("unexpected token_id in first item: got=%s want=%s", first["token_id"].(string), tokenID)
|
||||
}
|
||||
if strings.Contains(queryRec.Body.String(), "access_token") {
|
||||
t.Fatalf("audit query response must not contain access_token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenAPIAuditEventsNotReady(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := service.NewInMemoryTokenRuntime(nil)
|
||||
api := NewTokenAPI(runtime, noopAuditEmitter{}, time.Now)
|
||||
mux := http.NewServeMux()
|
||||
api.Register(mux)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/platform/tokens/audit-events?limit=3", nil)
|
||||
req.Header.Set("X-Request-Id", "req-audit-query-3")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotImplemented {
|
||||
t.Fatalf("expected not implemented: code=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, payload any) *bytes.Reader {
|
||||
t.Helper()
|
||||
buf, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal json failed: %v", err)
|
||||
}
|
||||
return bytes.NewReader(buf)
|
||||
}
|
||||
|
||||
func decodeMap(t *testing.T, raw []byte) map[string]any {
|
||||
t.Helper()
|
||||
out := map[string]any{}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("decode json failed: %v, raw=%s", err, string(raw))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type noopAuditEmitter struct{}
|
||||
|
||||
func (noopAuditEmitter) Emit(context.Context, service.AuditEvent) error {
|
||||
return nil
|
||||
}
|
||||
295
platform-token-runtime/internal/token/audit_executable_test.go
Normal file
295
platform-token-runtime/internal/token/audit_executable_test.go
Normal file
@@ -0,0 +1,295 @@
|
||||
package token_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lijiaoqiao/platform-token-runtime/internal/auth/middleware"
|
||||
"lijiaoqiao/platform-token-runtime/internal/auth/model"
|
||||
"lijiaoqiao/platform-token-runtime/internal/auth/service"
|
||||
)
|
||||
|
||||
func TestTOKAud001IssueSuccessEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
auditor := service.NewMemoryAuditEmitter()
|
||||
rt := service.NewInMemoryTokenRuntime(nil)
|
||||
|
||||
record, err := rt.IssueAndAudit(context.Background(), service.IssueTokenInput{
|
||||
SubjectID: "2001",
|
||||
Role: model.RoleOwner,
|
||||
Scope: []string{"supply:*"},
|
||||
TTL: 10 * time.Minute,
|
||||
RequestID: "req-aud-001",
|
||||
}, auditor)
|
||||
if err != nil {
|
||||
t.Fatalf("issue with audit failed: %v", err)
|
||||
}
|
||||
|
||||
event, ok := auditor.LastEvent()
|
||||
if !ok {
|
||||
t.Fatalf("expected issue success event")
|
||||
}
|
||||
if event.EventName != service.EventTokenIssueSuccess {
|
||||
t.Fatalf("unexpected event name: got=%s want=%s", event.EventName, service.EventTokenIssueSuccess)
|
||||
}
|
||||
assertAuditRequiredFields(t, event)
|
||||
if event.TokenID != record.TokenID {
|
||||
t.Fatalf("unexpected token_id in event: got=%s want=%s", event.TokenID, record.TokenID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOKAud002IssueFailEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
auditor := service.NewMemoryAuditEmitter()
|
||||
rt := service.NewInMemoryTokenRuntime(nil)
|
||||
|
||||
_, err := rt.IssueAndAudit(context.Background(), service.IssueTokenInput{
|
||||
SubjectID: "2001",
|
||||
Role: model.RoleOwner,
|
||||
Scope: []string{"supply:*"},
|
||||
TTL: 0,
|
||||
RequestID: "req-aud-002",
|
||||
}, auditor)
|
||||
if err == nil {
|
||||
t.Fatalf("expected issue failure")
|
||||
}
|
||||
|
||||
event, ok := auditor.LastEvent()
|
||||
if !ok {
|
||||
t.Fatalf("expected issue fail event")
|
||||
}
|
||||
if event.EventName != service.EventTokenIssueFail {
|
||||
t.Fatalf("unexpected event name: got=%s want=%s", event.EventName, service.EventTokenIssueFail)
|
||||
}
|
||||
assertAuditRequiredFields(t, event)
|
||||
if event.ResultCode != "ISSUE_FAILED" {
|
||||
t.Fatalf("unexpected result_code: got=%s want=ISSUE_FAILED", event.ResultCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOKAud003AuthnFailEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
auditor := service.NewMemoryAuditEmitter()
|
||||
rt := service.NewInMemoryTokenRuntime(nil)
|
||||
authorizer := service.NewScopeRoleAuthorizer()
|
||||
|
||||
handler := middleware.BuildTokenAuthChain(middleware.AuthMiddlewareConfig{
|
||||
Verifier: rt,
|
||||
StatusResolver: rt,
|
||||
Authorizer: authorizer,
|
||||
Auditor: auditor,
|
||||
}, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/supply/accounts", nil)
|
||||
req.Header.Set("Authorization", "Bearer invalid-token")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unexpected status code: got=%d want=%d", rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
event, ok := auditor.LastEvent()
|
||||
if !ok {
|
||||
t.Fatalf("expected audit event for authn failure")
|
||||
}
|
||||
if event.EventName != service.EventTokenAuthnFail {
|
||||
t.Fatalf("unexpected event name: got=%s want=%s", event.EventName, service.EventTokenAuthnFail)
|
||||
}
|
||||
if event.RequestID == "" {
|
||||
t.Fatalf("request_id must not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOKAud004AuthzDeniedEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
auditor := service.NewMemoryAuditEmitter()
|
||||
rt := service.NewInMemoryTokenRuntime(nil)
|
||||
authorizer := service.NewScopeRoleAuthorizer()
|
||||
|
||||
ctx := context.Background()
|
||||
viewer, err := rt.Issue(ctx, service.IssueTokenInput{
|
||||
SubjectID: "2002",
|
||||
Role: model.RoleViewer,
|
||||
Scope: []string{"supply:read"},
|
||||
TTL: 5 * time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("issue viewer token failed: %v", err)
|
||||
}
|
||||
|
||||
handler := middleware.BuildTokenAuthChain(middleware.AuthMiddlewareConfig{
|
||||
Verifier: rt,
|
||||
StatusResolver: rt,
|
||||
Authorizer: authorizer,
|
||||
Auditor: auditor,
|
||||
}, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/packages", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+viewer.AccessToken)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("unexpected status code: got=%d want=%d", rec.Code, http.StatusForbidden)
|
||||
}
|
||||
event, ok := auditor.LastEvent()
|
||||
if !ok {
|
||||
t.Fatalf("expected audit event for authz denial")
|
||||
}
|
||||
if event.EventName != service.EventTokenAuthzDenied {
|
||||
t.Fatalf("unexpected event name: got=%s want=%s", event.EventName, service.EventTokenAuthzDenied)
|
||||
}
|
||||
if event.SubjectID != viewer.SubjectID {
|
||||
t.Fatalf("unexpected subject_id: got=%s want=%s", event.SubjectID, viewer.SubjectID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOKAud005RevokeSuccessEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
auditor := service.NewMemoryAuditEmitter()
|
||||
rt := service.NewInMemoryTokenRuntime(nil)
|
||||
|
||||
record, err := rt.Issue(context.Background(), service.IssueTokenInput{
|
||||
SubjectID: "2001",
|
||||
Role: model.RoleOwner,
|
||||
Scope: []string{"supply:*"},
|
||||
TTL: 8 * time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("issue token failed: %v", err)
|
||||
}
|
||||
_, err = rt.RevokeAndAudit(context.Background(), record.TokenID, "operator_request", "req-aud-005", record.SubjectID, auditor)
|
||||
if err != nil {
|
||||
t.Fatalf("revoke with audit failed: %v", err)
|
||||
}
|
||||
|
||||
event, ok := auditor.LastEvent()
|
||||
if !ok {
|
||||
t.Fatalf("expected revoke success event")
|
||||
}
|
||||
if event.EventName != service.EventTokenRevokeSuccess {
|
||||
t.Fatalf("unexpected event name: got=%s want=%s", event.EventName, service.EventTokenRevokeSuccess)
|
||||
}
|
||||
assertAuditRequiredFields(t, event)
|
||||
if event.TokenID != record.TokenID {
|
||||
t.Fatalf("unexpected token_id in event: got=%s want=%s", event.TokenID, record.TokenID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOKAud006QueryKeyRejectedEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
auditor := service.NewMemoryAuditEmitter()
|
||||
rt := service.NewInMemoryTokenRuntime(nil)
|
||||
authorizer := service.NewScopeRoleAuthorizer()
|
||||
|
||||
handler := middleware.BuildTokenAuthChain(middleware.AuthMiddlewareConfig{
|
||||
Verifier: rt,
|
||||
StatusResolver: rt,
|
||||
Authorizer: authorizer,
|
||||
Auditor: auditor,
|
||||
}, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/supply/accounts?api_key=raw-secret-value", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unexpected status code: got=%d want=%d", rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
event, ok := auditor.LastEvent()
|
||||
if !ok {
|
||||
t.Fatalf("expected query key rejection audit event")
|
||||
}
|
||||
if event.EventName != service.EventTokenQueryKeyRejected {
|
||||
t.Fatalf("unexpected event name: got=%s want=%s", event.EventName, service.EventTokenQueryKeyRejected)
|
||||
}
|
||||
|
||||
serialized := strings.Join([]string{
|
||||
event.EventID,
|
||||
event.EventName,
|
||||
event.RequestID,
|
||||
event.TokenID,
|
||||
event.SubjectID,
|
||||
event.Route,
|
||||
event.ResultCode,
|
||||
event.ClientIP,
|
||||
}, "|")
|
||||
if strings.Contains(serialized, "raw-secret-value") {
|
||||
t.Fatalf("audit event must not contain raw query key value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOKAud007EventImmutability(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
auditor := service.NewMemoryAuditEmitter()
|
||||
rt := service.NewInMemoryTokenRuntime(nil)
|
||||
|
||||
issued, err := rt.IssueAndAudit(context.Background(), service.IssueTokenInput{
|
||||
SubjectID: "2001",
|
||||
Role: model.RoleOwner,
|
||||
Scope: []string{"supply:*"},
|
||||
TTL: 20 * time.Minute,
|
||||
RequestID: "req-aud-007-1",
|
||||
}, auditor)
|
||||
if err != nil {
|
||||
t.Fatalf("issue with audit failed: %v", err)
|
||||
}
|
||||
_, err = rt.RevokeAndAudit(context.Background(), issued.TokenID, "test", "req-aud-007-2", issued.SubjectID, auditor)
|
||||
if err != nil {
|
||||
t.Fatalf("revoke with audit failed: %v", err)
|
||||
}
|
||||
|
||||
firstRead := auditor.Events()
|
||||
secondRead := auditor.Events()
|
||||
if len(firstRead) < 2 || len(secondRead) < 2 {
|
||||
t.Fatalf("expected at least two audit events")
|
||||
}
|
||||
for idx := range firstRead {
|
||||
if firstRead[idx].EventID != secondRead[idx].EventID ||
|
||||
firstRead[idx].EventName != secondRead[idx].EventName ||
|
||||
!firstRead[idx].CreatedAt.Equal(secondRead[idx].CreatedAt) {
|
||||
t.Fatalf("event should be immutable across reads at index=%d", idx)
|
||||
}
|
||||
}
|
||||
for idx := 1; idx < len(firstRead); idx++ {
|
||||
if firstRead[idx].CreatedAt.Before(firstRead[idx-1].CreatedAt) {
|
||||
t.Fatalf("event timeline should be ordered by created_at")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertAuditRequiredFields(t *testing.T, event service.AuditEvent) {
|
||||
t.Helper()
|
||||
if event.EventID == "" {
|
||||
t.Fatalf("event_id must not be empty")
|
||||
}
|
||||
if event.RequestID == "" {
|
||||
t.Fatalf("request_id must not be empty")
|
||||
}
|
||||
if event.ResultCode == "" {
|
||||
t.Fatalf("result_code must not be empty")
|
||||
}
|
||||
if event.Route == "" {
|
||||
t.Fatalf("route must not be empty")
|
||||
}
|
||||
if event.CreatedAt.IsZero() {
|
||||
t.Fatalf("created_at must not be zero")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package token_test
|
||||
|
||||
import "testing"
|
||||
|
||||
type auditTemplateCase struct {
|
||||
ID string
|
||||
Name string
|
||||
TriggerCase string
|
||||
Assertions []string
|
||||
}
|
||||
|
||||
func TestTokenAuditTemplateCases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []auditTemplateCase{
|
||||
{
|
||||
ID: "TOK-AUD-001",
|
||||
Name: "签发成功事件",
|
||||
TriggerCase: "TOK-LIFE-001",
|
||||
Assertions: []string{
|
||||
"存在 token.issue.success",
|
||||
"event_id/request_id/result_code/route/created_at 齐全",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "TOK-AUD-002",
|
||||
Name: "签发失败事件",
|
||||
TriggerCase: "TOK-LIFE-002",
|
||||
Assertions: []string{
|
||||
"存在 token.issue.fail",
|
||||
"result_code 准确",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "TOK-AUD-003",
|
||||
Name: "鉴权失败事件",
|
||||
TriggerCase: "无效 token 访问受保护接口",
|
||||
Assertions: []string{
|
||||
"存在 token.authn.fail",
|
||||
"包含 request_id",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "TOK-AUD-004",
|
||||
Name: "越权事件",
|
||||
TriggerCase: "TOK-LIFE-008",
|
||||
Assertions: []string{
|
||||
"存在 token.authz.denied",
|
||||
"包含 subject_id",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "TOK-AUD-005",
|
||||
Name: "吊销事件",
|
||||
TriggerCase: "TOK-LIFE-005",
|
||||
Assertions: []string{
|
||||
"存在 token.revoke.success",
|
||||
"包含 token_id",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "TOK-AUD-006",
|
||||
Name: "query key 拒绝事件",
|
||||
TriggerCase: "query key 访问受保护接口",
|
||||
Assertions: []string{
|
||||
"存在 token.query_key.rejected",
|
||||
"不含敏感值",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "TOK-AUD-007",
|
||||
Name: "事件不可篡改",
|
||||
TriggerCase: "重复读取同 event_id",
|
||||
Assertions: []string{
|
||||
"核心字段不可变",
|
||||
"时间顺序正确",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.ID, func(t *testing.T) {
|
||||
t.Skipf("模板用例,待接入实现: %s", tc.Name)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package token_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lijiaoqiao/platform-token-runtime/internal/auth/middleware"
|
||||
"lijiaoqiao/platform-token-runtime/internal/auth/model"
|
||||
"lijiaoqiao/platform-token-runtime/internal/auth/service"
|
||||
)
|
||||
|
||||
func TestTOKLife001IssueSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := service.NewInMemoryTokenRuntime(nil)
|
||||
ctx := context.Background()
|
||||
|
||||
first, err := rt.Issue(ctx, service.IssueTokenInput{
|
||||
SubjectID: "2001",
|
||||
Role: model.RoleOwner,
|
||||
Scope: []string{"supply:*"},
|
||||
TTL: 30 * time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("issue token failed: %v", err)
|
||||
}
|
||||
second, err := rt.Issue(ctx, service.IssueTokenInput{
|
||||
SubjectID: "2001",
|
||||
Role: model.RoleOwner,
|
||||
Scope: []string{"supply:*"},
|
||||
TTL: 30 * time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("issue second token failed: %v", err)
|
||||
}
|
||||
|
||||
if first.Status != service.TokenStatusActive {
|
||||
t.Fatalf("unexpected status: got=%s want=%s", first.Status, service.TokenStatusActive)
|
||||
}
|
||||
if !first.ExpiresAt.After(first.IssuedAt) {
|
||||
t.Fatalf("expires_at must be greater than issued_at")
|
||||
}
|
||||
if first.TokenID == second.TokenID {
|
||||
t.Fatalf("token_id should be unique")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOKLife002IssueInvalidInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := service.NewInMemoryTokenRuntime(nil)
|
||||
ctx := context.Background()
|
||||
_, err := rt.Issue(ctx, service.IssueTokenInput{
|
||||
SubjectID: "2001",
|
||||
Role: model.RoleOwner,
|
||||
Scope: []string{"supply:*"},
|
||||
TTL: 0,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for invalid ttl_seconds")
|
||||
}
|
||||
if got := rt.TokenCount(); got != 0 {
|
||||
t.Fatalf("unexpected token count after invalid issue: got=%d want=0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOKLife003IssueIdempotencyReplay(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := service.NewInMemoryTokenRuntime(nil)
|
||||
ctx := context.Background()
|
||||
|
||||
first, err := rt.Issue(ctx, service.IssueTokenInput{
|
||||
SubjectID: "2001",
|
||||
Role: model.RoleOwner,
|
||||
Scope: []string{"supply:*"},
|
||||
TTL: 30 * time.Minute,
|
||||
IdempotencyKey: "idem-life-003",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first issue failed: %v", err)
|
||||
}
|
||||
second, err := rt.Issue(ctx, service.IssueTokenInput{
|
||||
SubjectID: "2001",
|
||||
Role: model.RoleOwner,
|
||||
Scope: []string{"supply:*"},
|
||||
TTL: 30 * time.Minute,
|
||||
IdempotencyKey: "idem-life-003",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("replay issue failed: %v", err)
|
||||
}
|
||||
|
||||
if first.TokenID != second.TokenID {
|
||||
t.Fatalf("replayed issue must return same token_id: first=%s second=%s", first.TokenID, second.TokenID)
|
||||
}
|
||||
if got := rt.TokenCount(); got != 1 {
|
||||
t.Fatalf("idempotent replay must not create duplicate token: got=%d want=1", got)
|
||||
}
|
||||
|
||||
_, err = rt.Issue(ctx, service.IssueTokenInput{
|
||||
SubjectID: "2001",
|
||||
Role: model.RoleOwner,
|
||||
Scope: []string{"supply:read"},
|
||||
TTL: 30 * time.Minute,
|
||||
IdempotencyKey: "idem-life-003",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected payload mismatch conflict for same idempotency key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOKLife004RefreshSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := service.NewInMemoryTokenRuntime(nil)
|
||||
ctx := context.Background()
|
||||
|
||||
issued, err := rt.Issue(ctx, service.IssueTokenInput{
|
||||
SubjectID: "2001",
|
||||
Role: model.RoleOwner,
|
||||
Scope: []string{"supply:*"},
|
||||
TTL: 1 * time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("issue token failed: %v", err)
|
||||
}
|
||||
previousExpiresAt := issued.ExpiresAt
|
||||
|
||||
refreshed, err := rt.Refresh(ctx, issued.TokenID, 15*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("refresh token failed: %v", err)
|
||||
}
|
||||
|
||||
if refreshed.Status != service.TokenStatusActive {
|
||||
t.Fatalf("unexpected status after refresh: got=%s want=%s", refreshed.Status, service.TokenStatusActive)
|
||||
}
|
||||
if !refreshed.ExpiresAt.After(previousExpiresAt) {
|
||||
t.Fatalf("expires_at should be delayed after refresh")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOKLife005RevokeSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
start := time.Now()
|
||||
rt := service.NewInMemoryTokenRuntime(nil)
|
||||
ctx := context.Background()
|
||||
|
||||
issued, err := rt.Issue(ctx, service.IssueTokenInput{
|
||||
SubjectID: "2001",
|
||||
Role: model.RoleOwner,
|
||||
Scope: []string{"supply:*"},
|
||||
TTL: 10 * time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("issue token failed: %v", err)
|
||||
}
|
||||
if _, err := rt.Revoke(ctx, issued.TokenID, "security_event"); err != nil {
|
||||
t.Fatalf("revoke token failed: %v", err)
|
||||
}
|
||||
|
||||
introspected, err := rt.Introspect(ctx, issued.AccessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("introspect failed: %v", err)
|
||||
}
|
||||
if introspected.Status != service.TokenStatusRevoked {
|
||||
t.Fatalf("unexpected status after revoke: got=%s want=%s", introspected.Status, service.TokenStatusRevoked)
|
||||
}
|
||||
if time.Since(start) > 5*time.Second {
|
||||
t.Fatalf("revoke propagation exceeded 5 seconds in in-memory runtime")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOKLife006RevokedTokenAccessDenied(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
auditor := service.NewMemoryAuditEmitter()
|
||||
rt := service.NewInMemoryTokenRuntime(nil)
|
||||
authorizer := service.NewScopeRoleAuthorizer()
|
||||
ctx := context.Background()
|
||||
|
||||
issued, err := rt.Issue(ctx, service.IssueTokenInput{
|
||||
SubjectID: "2001",
|
||||
Role: model.RoleOwner,
|
||||
Scope: []string{"supply:*"},
|
||||
TTL: 5 * time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("issue token failed: %v", err)
|
||||
}
|
||||
if _, err := rt.Revoke(ctx, issued.TokenID, "test_revoke"); err != nil {
|
||||
t.Fatalf("revoke failed: %v", err)
|
||||
}
|
||||
|
||||
handler := middleware.BuildTokenAuthChain(middleware.AuthMiddlewareConfig{
|
||||
Verifier: rt,
|
||||
StatusResolver: rt,
|
||||
Authorizer: authorizer,
|
||||
Auditor: auditor,
|
||||
}, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/supply/accounts", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+issued.AccessToken)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unexpected status code: got=%d want=%d", rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
if code := decodeMiddlewareErrorCode(t, rec); code != service.CodeAuthTokenInactive {
|
||||
t.Fatalf("unexpected error code: got=%s want=%s", code, service.CodeAuthTokenInactive)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOKLife007ExpiredTokenInactive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
current := time.Date(2026, 3, 29, 15, 0, 0, 0, time.UTC)
|
||||
rt := service.NewInMemoryTokenRuntime(func() time.Time { return current })
|
||||
ctx := context.Background()
|
||||
|
||||
issued, err := rt.Issue(ctx, service.IssueTokenInput{
|
||||
SubjectID: "2001",
|
||||
Role: model.RoleOwner,
|
||||
Scope: []string{"supply:*"},
|
||||
TTL: 2 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("issue token failed: %v", err)
|
||||
}
|
||||
current = current.Add(3 * time.Second)
|
||||
|
||||
introspected, err := rt.Introspect(ctx, issued.AccessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("introspect failed: %v", err)
|
||||
}
|
||||
if introspected.Status != service.TokenStatusExpired {
|
||||
t.Fatalf("unexpected token status: got=%s want=%s", introspected.Status, service.TokenStatusExpired)
|
||||
}
|
||||
|
||||
auditor := service.NewMemoryAuditEmitter()
|
||||
authorizer := service.NewScopeRoleAuthorizer()
|
||||
handler := middleware.BuildTokenAuthChain(middleware.AuthMiddlewareConfig{
|
||||
Verifier: rt,
|
||||
StatusResolver: rt,
|
||||
Authorizer: authorizer,
|
||||
Auditor: auditor,
|
||||
}, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/supply/accounts", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+issued.AccessToken)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unexpected status code: got=%d want=%d", rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
if code := decodeMiddlewareErrorCode(t, rec); code != service.CodeAuthTokenInactive {
|
||||
t.Fatalf("unexpected error code: got=%s want=%s", code, service.CodeAuthTokenInactive)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOKLife008ViewerWriteDenied(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
auditor := service.NewMemoryAuditEmitter()
|
||||
rt := service.NewInMemoryTokenRuntime(nil)
|
||||
authorizer := service.NewScopeRoleAuthorizer()
|
||||
|
||||
ctx := context.Background()
|
||||
viewer, err := rt.Issue(ctx, service.IssueTokenInput{
|
||||
SubjectID: "2002",
|
||||
Role: model.RoleViewer,
|
||||
Scope: []string{"supply:read"},
|
||||
TTL: 10 * time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("issue viewer token failed: %v", err)
|
||||
}
|
||||
|
||||
nextCalled := false
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
nextCalled = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
handler := middleware.BuildTokenAuthChain(middleware.AuthMiddlewareConfig{
|
||||
Verifier: rt,
|
||||
StatusResolver: rt,
|
||||
Authorizer: authorizer,
|
||||
Auditor: auditor,
|
||||
}, next)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/packages", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+viewer.AccessToken)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("unexpected status code: got=%d want=%d", rec.Code, http.StatusForbidden)
|
||||
}
|
||||
if code := decodeMiddlewareErrorCode(t, rec); code != service.CodeAuthScopeDenied {
|
||||
t.Fatalf("unexpected error code: got=%s want=%s", code, service.CodeAuthScopeDenied)
|
||||
}
|
||||
if nextCalled {
|
||||
t.Fatalf("write handler should be blocked for viewer token")
|
||||
}
|
||||
}
|
||||
|
||||
type middlewareErrorEnvelope struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
func decodeMiddlewareErrorCode(t *testing.T, rec *httptest.ResponseRecorder) string {
|
||||
t.Helper()
|
||||
var envelope middlewareErrorEnvelope
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("failed to decode middleware error response: %v", err)
|
||||
}
|
||||
return envelope.Error.Code
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package token_test
|
||||
|
||||
import "testing"
|
||||
|
||||
// 说明:
|
||||
// 1. 本文件保留完整 TOK-LIFE 模板清单作为覆盖基线。
|
||||
// 2. 首批可执行用例已在 lifecycle_executable_test.go 落地:
|
||||
// TOK-LIFE-001 / TOK-LIFE-004 / TOK-LIFE-005 / TOK-LIFE-008。
|
||||
|
||||
type lifecycleTemplateCase struct {
|
||||
ID string
|
||||
Name string
|
||||
Preconditions []string
|
||||
Steps []string
|
||||
Assertions []string
|
||||
}
|
||||
|
||||
func TestTokenLifecycleTemplateCases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []lifecycleTemplateCase{
|
||||
{
|
||||
ID: "TOK-LIFE-001",
|
||||
Name: "签发成功",
|
||||
Preconditions: []string{
|
||||
"tenant_id=1001",
|
||||
"subject_owner=2001",
|
||||
},
|
||||
Steps: []string{
|
||||
"调用 POST /api/v1/platform/tokens/issue",
|
||||
"记录 token_id/issued_at/expires_at/status",
|
||||
},
|
||||
Assertions: []string{
|
||||
"status=active",
|
||||
"expires_at>issued_at",
|
||||
"token_id 唯一",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "TOK-LIFE-002",
|
||||
Name: "签发参数非法",
|
||||
Preconditions: []string{
|
||||
"ttl_seconds 超上限",
|
||||
},
|
||||
Steps: []string{
|
||||
"调用 POST /api/v1/platform/tokens/issue",
|
||||
},
|
||||
Assertions: []string{
|
||||
"返回 400",
|
||||
"不落 active token",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "TOK-LIFE-003",
|
||||
Name: "幂等签发重放",
|
||||
Steps: []string{
|
||||
"相同 Idempotency-Key 重复调用签发接口",
|
||||
},
|
||||
Assertions: []string{
|
||||
"返回同一 token_id",
|
||||
"无重复写入",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "TOK-LIFE-004",
|
||||
Name: "续期成功",
|
||||
Steps: []string{
|
||||
"调用 POST /api/v1/platform/tokens/{tokenId}/refresh",
|
||||
},
|
||||
Assertions: []string{
|
||||
"expires_at 延后",
|
||||
"status=active",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "TOK-LIFE-005",
|
||||
Name: "吊销成功",
|
||||
Steps: []string{
|
||||
"调用 POST /api/v1/platform/tokens/{tokenId}/revoke",
|
||||
"立即调用 introspect 查询状态",
|
||||
},
|
||||
Assertions: []string{
|
||||
"status 最终为 revoked",
|
||||
"吊销生效延迟 <=5s",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "TOK-LIFE-006",
|
||||
Name: "吊销后访问受限接口",
|
||||
Steps: []string{
|
||||
"使用已吊销 token 访问受保护接口",
|
||||
},
|
||||
Assertions: []string{
|
||||
"返回 401 AUTH_TOKEN_INACTIVE",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "TOK-LIFE-007",
|
||||
Name: "过期自动失效",
|
||||
Steps: []string{
|
||||
"签发短 TTL token",
|
||||
"等待 token 过期",
|
||||
"调用 introspect 查询状态",
|
||||
},
|
||||
Assertions: []string{
|
||||
"status=expired",
|
||||
"返回不可用错误",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "TOK-LIFE-008",
|
||||
Name: "viewer 越权写操作",
|
||||
Preconditions: []string{
|
||||
"viewer scope=supply:read",
|
||||
},
|
||||
Steps: []string{
|
||||
"viewer token 调用写接口",
|
||||
},
|
||||
Assertions: []string{
|
||||
"返回 403 AUTH_SCOPE_DENIED",
|
||||
"无写入副作用",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.ID, func(t *testing.T) {
|
||||
t.Skipf("模板用例,待接入实现: %s", tc.Name)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user