212 lines
7.4 KiB
Go
212 lines
7.4 KiB
Go
package securityevents
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
|
)
|
|
|
|
type StateRepository interface {
|
|
EnsureSecurityEventStreamState(context.Context, string, string, string) error
|
|
BeginSecurityEventVerification(context.Context, string, string, []byte, time.Time) error
|
|
RecordSecurityEventHeartbeatFailure(context.Context, string, string, string) error
|
|
AdvanceSecurityEventStreamState(context.Context, string, string, time.Time, time.Duration) error
|
|
EvaluateOIDCSecurityEvent(context.Context, string, string, string, string, string, time.Time, time.Time, time.Duration) (store.SecurityEventEvaluation, error)
|
|
}
|
|
|
|
type ServiceConfig struct {
|
|
Issuer string
|
|
Audience string
|
|
StreamID string
|
|
ManagementTokenURL string
|
|
ManagementClientID string
|
|
ManagementSecret string
|
|
HeartbeatInterval time.Duration
|
|
StaleAfter time.Duration
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
type Service struct {
|
|
repository StateRepository
|
|
config ServiceConfig
|
|
client *http.Client
|
|
clock func() time.Time
|
|
tokenMu sync.Mutex
|
|
token string
|
|
tokenUntil time.Time
|
|
metrics *Metrics
|
|
}
|
|
|
|
func (s *Service) SetMetrics(metrics *Metrics) { s.metrics = metrics }
|
|
|
|
func NewService(repository StateRepository, config ServiceConfig) (*Service, error) {
|
|
if repository == nil || config.Issuer == "" || config.Audience == "" || config.StreamID == "" ||
|
|
config.ManagementTokenURL == "" || config.ManagementClientID == "" || config.ManagementSecret == "" {
|
|
return nil, errors.New("SSF heartbeat configuration is incomplete")
|
|
}
|
|
if config.HeartbeatInterval <= 0 || config.StaleAfter < 2*config.HeartbeatInterval {
|
|
return nil, errors.New("SSF heartbeat timing is invalid")
|
|
}
|
|
client := config.HTTPClient
|
|
if client == nil {
|
|
transport := http.DefaultTransport.(*http.Transport).Clone()
|
|
transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
|
|
transport.DisableCompression = true
|
|
client = &http.Client{Timeout: 10 * time.Second,
|
|
Transport: transport,
|
|
CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}
|
|
}
|
|
return &Service{repository: repository, config: config, client: client, clock: time.Now}, nil
|
|
}
|
|
|
|
func (s *Service) Start(ctx context.Context) error {
|
|
if err := s.repository.EnsureSecurityEventStreamState(ctx, s.config.Issuer, s.config.Audience, s.config.StreamID); err != nil {
|
|
return err
|
|
}
|
|
go s.run(ctx)
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) Evaluate(ctx context.Context, identity auth.OIDCSecurityEventIdentity) (auth.OIDCSecurityEventEvaluation, error) {
|
|
result, err := s.repository.EvaluateOIDCSecurityEvent(
|
|
ctx, s.config.Issuer, s.config.Audience, identity.Issuer, identity.TenantID, identity.Subject,
|
|
identity.IssuedAt, s.clock().UTC(), s.config.StaleAfter,
|
|
)
|
|
if err != nil {
|
|
return auth.OIDCSecurityEventEvaluation{}, err
|
|
}
|
|
if result.Revoked && s.metrics != nil {
|
|
s.metrics.ObserveWatermarkRejection()
|
|
}
|
|
return auth.OIDCSecurityEventEvaluation{Revoked: result.Revoked, RequireIntrospection: result.RequireIntrospection}, nil
|
|
}
|
|
|
|
func (s *Service) run(ctx context.Context) {
|
|
s.sendVerification(ctx)
|
|
ticker := time.NewTicker(s.config.HeartbeatInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
s.sendVerification(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Service) sendVerification(ctx context.Context) {
|
|
outcome := "failed"
|
|
defer func() {
|
|
if s.metrics != nil {
|
|
s.metrics.ObserveHeartbeat(outcome)
|
|
}
|
|
}()
|
|
if err := s.repository.AdvanceSecurityEventStreamState(ctx, s.config.Issuer, s.config.Audience, s.clock().UTC(), s.config.StaleAfter); err != nil {
|
|
_ = s.repository.RecordSecurityEventHeartbeatFailure(ctx, s.config.Issuer, s.config.Audience, "state_advance_failed")
|
|
}
|
|
state, err := randomVerificationState()
|
|
if err != nil {
|
|
_ = s.repository.RecordSecurityEventHeartbeatFailure(ctx, s.config.Issuer, s.config.Audience, "state_generation_failed")
|
|
return
|
|
}
|
|
hash := sha256.Sum256([]byte(state))
|
|
now := s.clock().UTC()
|
|
if err := s.repository.BeginSecurityEventVerification(ctx, s.config.Issuer, s.config.Audience, hash[:], now); err != nil {
|
|
_ = s.repository.RecordSecurityEventHeartbeatFailure(ctx, s.config.Issuer, s.config.Audience, "state_persistence_failed")
|
|
return
|
|
}
|
|
token, err := s.managementToken(ctx)
|
|
if err != nil {
|
|
_ = s.repository.RecordSecurityEventHeartbeatFailure(ctx, s.config.Issuer, s.config.Audience, "management_token_failed")
|
|
return
|
|
}
|
|
payload, _ := json.Marshal(map[string]string{"stream_id": s.config.StreamID, "state": state})
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(s.config.Issuer, "/")+"/v1/verify", bytes.NewReader(payload))
|
|
if err != nil {
|
|
return
|
|
}
|
|
request.Header.Set("Authorization", "Bearer "+token)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
response, err := s.client.Do(request)
|
|
if err != nil {
|
|
_ = s.repository.RecordSecurityEventHeartbeatFailure(ctx, s.config.Issuer, s.config.Audience, "verification_request_failed")
|
|
return
|
|
}
|
|
defer response.Body.Close()
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024))
|
|
if response.StatusCode != http.StatusNoContent {
|
|
_ = s.repository.RecordSecurityEventHeartbeatFailure(ctx, s.config.Issuer, s.config.Audience, fmt.Sprintf("verification_http_%d", response.StatusCode))
|
|
return
|
|
}
|
|
outcome = "accepted"
|
|
}
|
|
|
|
func (s *Service) managementToken(ctx context.Context) (string, error) {
|
|
s.tokenMu.Lock()
|
|
defer s.tokenMu.Unlock()
|
|
if s.token != "" && s.clock().Before(s.tokenUntil) {
|
|
return s.token, nil
|
|
}
|
|
form := url.Values{"grant_type": {"client_credentials"}, "scope": {"ssf.stream.read ssf.stream.verify"}}
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, s.config.ManagementTokenURL, strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
request.SetBasicAuth(s.config.ManagementClientID, s.config.ManagementSecret)
|
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
response, err := s.client.Do(request)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode != http.StatusOK {
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024))
|
|
return "", fmt.Errorf("management token endpoint returned HTTP %d", response.StatusCode)
|
|
}
|
|
payload, err := io.ReadAll(io.LimitReader(response.Body, 64*1024+1))
|
|
if err != nil || len(payload) > 64*1024 {
|
|
return "", errors.New("management token response is too large")
|
|
}
|
|
var result struct {
|
|
AccessToken string `json:"access_token"`
|
|
TokenType string `json:"token_type"`
|
|
ExpiresIn int64 `json:"expires_in"`
|
|
}
|
|
if json.Unmarshal(payload, &result) != nil || result.AccessToken == "" || !strings.EqualFold(result.TokenType, "Bearer") {
|
|
return "", errors.New("management token response is invalid")
|
|
}
|
|
if result.ExpiresIn <= 0 {
|
|
result.ExpiresIn = 60
|
|
}
|
|
cacheFor := time.Duration(result.ExpiresIn) * time.Second
|
|
if cacheFor > 30*time.Second {
|
|
cacheFor -= 30 * time.Second
|
|
}
|
|
s.token, s.tokenUntil = result.AccessToken, s.clock().Add(cacheFor)
|
|
return s.token, nil
|
|
}
|
|
|
|
func randomVerificationState() (string, error) {
|
|
payload := make([]byte, 32)
|
|
if _, err := rand.Read(payload); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(payload), nil
|
|
}
|