feat: sync lijiaoqiao implementation and staging validation artifacts

This commit is contained in:
Your Name
2026-03-31 13:40:00 +08:00
parent 0e5ecd930e
commit e9338dec28
686 changed files with 29213 additions and 168 deletions

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View 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
}

View 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
}

View 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
}