Files
easyai-ai-gateway/apps/api/internal/securityevents/verifier.go
T
chengcheng 5c679ff13f feat(identity): 接入认证中心多租户登录
支持 Manifest V2 动态 tid 验证、Tenant Context 同步和租户内 JIT 投影,并保留 Manifest V1 与旧 Session 兼容。\n\n增加 tenantHint、租户切换、普通注册关闭及 application/principal/tenant 两级 SSF 撤销;迁移、定向安全测试和本地双租户跨仓 E2E 已通过。\n\nrelease_required=true;未执行 Release、Staging 或真实链路。
2026-07-28 17:28:35 +08:00

429 lines
14 KiB
Go

package securityevents
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
const (
SessionRevokedEventType = "https://schemas.openid.net/secevent/caep/event-type/session-revoked"
VerificationEventType = "https://schemas.openid.net/secevent/ssf/event-type/verification"
StreamUpdatedEventType = "https://schemas.openid.net/secevent/ssf/event-type/stream-updated"
)
type Event struct {
Issuer string
Audience string
JTI string
TransactionID string
IssuedAt time.Time
EventType string
EventTimestamp time.Time
SubjectIssuer string
ApplicationID string
SubjectType string
Subject string
TenantID string
InitiatingEntity string
StreamID string
State string
StreamStatus string
Reason string
}
type VerifierConfig struct {
TransmitterIssuer string
Audience string
SubjectIssuer string
TenantID string
ApplicationID string
TenantMode string
StreamID string
ClockSkew time.Duration
HTTPClient *http.Client
}
type Verifier struct {
config VerifierConfig
client *http.Client
mu sync.Mutex
keys map[string]*ecdsa.PublicKey
expiresAt time.Time
metrics *Metrics
clock func() time.Time
}
func (v *Verifier) SetMetrics(metrics *Metrics) { v.metrics = metrics }
type transmitterMetadata struct {
Issuer string `json:"issuer"`
JWKSURI string `json:"jwks_uri"`
}
type jwkSet struct {
Keys []struct {
KID string `json:"kid"`
KTY string `json:"kty"`
Use string `json:"use"`
Alg string `json:"alg"`
Crv string `json:"crv"`
X string `json:"x"`
Y string `json:"y"`
} `json:"keys"`
}
type protocolError struct {
Code string
}
func (e *protocolError) Error() string { return e.Code }
func NewVerifier(config VerifierConfig) (*Verifier, error) {
config.TransmitterIssuer = strings.TrimRight(strings.TrimSpace(config.TransmitterIssuer), "/")
config.SubjectIssuer = strings.TrimRight(strings.TrimSpace(config.SubjectIssuer), "/")
multiTenant := config.TenantMode == "multi_tenant"
validBinding := multiTenant && uuid.Validate(config.ApplicationID) == nil ||
!multiTenant && uuid.Validate(config.TenantID) == nil
if !absoluteHTTPURL(config.TransmitterIssuer) || !absoluteHTTPURL(config.SubjectIssuer) || config.Audience == "" || !validBinding {
return nil, errors.New("SSF transmitter, audience, subject issuer, and identity binding are required")
}
if _, err := uuid.Parse(config.StreamID); err != nil {
return nil, errors.New("SSF stream id must be a UUID")
}
if config.ClockSkew < 0 || config.ClockSkew > 5*time.Minute {
return nil, errors.New("SSF clock skew 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 &Verifier{config: config, client: client, keys: make(map[string]*ecdsa.PublicKey), clock: time.Now}, nil
}
func (v *Verifier) Verify(ctx context.Context, raw string) (Event, error) {
parser := jwt.NewParser(jwt.WithValidMethods([]string{"ES256"}))
unverified, _, err := parser.ParseUnverified(raw, jwt.MapClaims{})
if err != nil || unverified == nil || unverified.Header["typ"] != "secevent+jwt" || unverified.Header["alg"] != "ES256" {
return Event{}, &protocolError{Code: "invalid_request"}
}
unverifiedClaims, ok := unverified.Claims.(jwt.MapClaims)
if !ok {
return Event{}, &protocolError{Code: "invalid_request"}
}
if stringClaim(unverifiedClaims, "iss") != v.config.TransmitterIssuer {
return Event{}, &protocolError{Code: "invalid_issuer"}
}
if !claimHasAudience(unverifiedClaims["aud"], v.config.Audience) {
return Event{}, &protocolError{Code: "invalid_audience"}
}
kid, _ := unverified.Header["kid"].(string)
if kid == "" {
return Event{}, &protocolError{Code: "invalid_key"}
}
key, err := v.key(ctx, kid)
if err != nil {
return Event{}, &protocolError{Code: "invalid_key"}
}
token, err := jwt.Parse(raw, func(token *jwt.Token) (any, error) { return key, nil },
jwt.WithValidMethods([]string{"ES256"}), jwt.WithIssuer(v.config.TransmitterIssuer),
jwt.WithAudience(v.config.Audience), jwt.WithIssuedAt(), jwt.WithLeeway(v.config.ClockSkew))
if err != nil || !token.Valid {
if errors.Is(err, jwt.ErrTokenSignatureInvalid) {
return Event{}, &protocolError{Code: "invalid_key"}
}
return Event{}, &protocolError{Code: "invalid_request"}
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return Event{}, &protocolError{Code: "invalid_request"}
}
if _, present := claims["sub"]; present {
return Event{}, &protocolError{Code: "invalid_request"}
}
if _, present := claims["exp"]; present {
return Event{}, &protocolError{Code: "invalid_request"}
}
jti := stringClaim(claims, "jti")
if _, err := uuid.Parse(jti); err != nil {
return Event{}, &protocolError{Code: "invalid_request"}
}
issuedAt, err := claims.GetIssuedAt()
if err != nil || issuedAt == nil {
return Event{}, &protocolError{Code: "invalid_request"}
}
events, ok := claims["events"].(map[string]any)
if !ok || len(events) != 1 {
return Event{}, &protocolError{Code: "invalid_request"}
}
event := Event{Issuer: v.config.TransmitterIssuer, Audience: v.config.Audience, JTI: jti, TransactionID: stringClaim(claims, "txn"), IssuedAt: issuedAt.Time}
if payload, present := events[SessionRevokedEventType]; present {
value, ok := payload.(map[string]any)
if !ok || event.TransactionID == "" {
return Event{}, &protocolError{Code: "invalid_request"}
}
if _, err := uuid.Parse(event.TransactionID); err != nil {
return Event{}, &protocolError{Code: "invalid_request"}
}
event.EventType, event.EventTimestamp, event.InitiatingEntity = SessionRevokedEventType, unixClaim(value["event_timestamp"]), stringValue(value["initiating_entity"])
event.ApplicationID, event.SubjectType = stringValue(value["application_id"]), stringValue(value["subject_type"])
if event.EventTimestamp.IsZero() || event.EventTimestamp.After(v.now().Add(v.config.ClockSkew)) ||
!validInitiator(event.InitiatingEntity) || !v.readSessionSubject(claims["sub_id"], &event) {
return Event{}, &protocolError{Code: "invalid_request"}
}
return event, nil
}
if payload, present := events[VerificationEventType]; present {
value, ok := payload.(map[string]any)
if !ok || !v.readStreamSubject(claims["sub_id"], &event) {
return Event{}, &protocolError{Code: "invalid_request"}
}
event.EventType = VerificationEventType
if rawState, present := value["state"]; present {
var valid bool
event.State, valid = rawState.(string)
if !valid || len(event.State) > 1024 {
return Event{}, &protocolError{Code: "invalid_request"}
}
}
if len(event.State) > 1024 {
return Event{}, &protocolError{Code: "invalid_request"}
}
return event, nil
}
if payload, present := events[StreamUpdatedEventType]; present {
value, ok := payload.(map[string]any)
if !ok || !v.readStreamSubject(claims["sub_id"], &event) {
return Event{}, &protocolError{Code: "invalid_request"}
}
event.EventType = StreamUpdatedEventType
event.StreamStatus, event.Reason = stringValue(value["status"]), stringValue(value["reason"])
if event.StreamStatus != "enabled" && event.StreamStatus != "paused" && event.StreamStatus != "disabled" {
return Event{}, &protocolError{Code: "invalid_request"}
}
if len(event.Reason) > 1024 {
return Event{}, &protocolError{Code: "invalid_request"}
}
return event, nil
}
return Event{}, &protocolError{Code: "invalid_request"}
}
func claimHasAudience(raw any, expected string) bool {
switch value := raw.(type) {
case string:
return value == expected
case []string:
for _, audience := range value {
if audience == expected {
return true
}
}
case []any:
for _, item := range value {
if audience, ok := item.(string); ok && audience == expected {
return true
}
}
}
return false
}
func (v *Verifier) now() time.Time {
if v.clock != nil {
return v.clock().UTC()
}
return time.Now().UTC()
}
func (v *Verifier) readSessionSubject(raw any, event *Event) bool {
subject, ok := raw.(map[string]any)
if !ok || stringValue(subject["format"]) != "complex" {
return false
}
user, userOK := subject["user"].(map[string]any)
tenant, tenantOK := subject["tenant"].(map[string]any)
if !userOK || !tenantOK || stringValue(user["format"]) != "iss_sub" || stringValue(tenant["format"]) != "opaque" {
return false
}
event.SubjectIssuer, event.Subject, event.TenantID = strings.TrimRight(stringValue(user["iss"]), "/"), stringValue(user["sub"]), stringValue(tenant["id"])
if event.SubjectIssuer != v.config.SubjectIssuer || uuid.Validate(event.TenantID) != nil {
return false
}
if v.config.TenantMode == "multi_tenant" {
if event.ApplicationID != v.config.ApplicationID ||
event.SubjectType != "principal" && event.SubjectType != "tenant" ||
event.SubjectType == "tenant" && event.Subject != event.TenantID {
return false
}
} else if event.ApplicationID != "" || event.SubjectType != "" || event.TenantID != v.config.TenantID {
return false
}
return uuid.Validate(event.Subject) == nil
}
func (v *Verifier) readStreamSubject(raw any, event *Event) bool {
subject, ok := raw.(map[string]any)
if !ok || stringValue(subject["format"]) != "opaque" || stringValue(subject["id"]) != v.config.StreamID {
return false
}
event.StreamID = v.config.StreamID
return true
}
func (v *Verifier) key(ctx context.Context, kid string) (*ecdsa.PublicKey, error) {
v.mu.Lock()
if key := v.keys[kid]; key != nil && time.Now().Before(v.expiresAt) {
v.mu.Unlock()
return key, nil
}
v.mu.Unlock()
if err := v.refresh(ctx); err != nil {
if v.metrics != nil {
v.metrics.ObserveJWKSRefreshFailure("ssf")
}
return nil, err
}
v.mu.Lock()
defer v.mu.Unlock()
key := v.keys[kid]
if key == nil {
return nil, errors.New("SSF signing key is unavailable")
}
return key, nil
}
func (v *Verifier) refresh(ctx context.Context) error {
v.mu.Lock()
defer v.mu.Unlock()
configurationURL, err := ssfConfigurationURL(v.config.TransmitterIssuer)
if err != nil {
return err
}
var metadata transmitterMetadata
if err := v.fetchJSON(ctx, configurationURL, &metadata); err != nil {
return err
}
if strings.TrimRight(metadata.Issuer, "/") != v.config.TransmitterIssuer || !sameOrigin(metadata.JWKSURI, v.config.TransmitterIssuer) {
return errors.New("SSF metadata is not bound to configured issuer")
}
var set jwkSet
if err := v.fetchJSON(ctx, metadata.JWKSURI, &set); err != nil {
return err
}
keys := make(map[string]*ecdsa.PublicKey)
for _, item := range set.Keys {
if item.KID == "" || item.KTY != "EC" || item.Alg != "ES256" || item.Crv != "P-256" || item.Use != "sig" {
continue
}
x, xErr := decodeCoordinate(item.X)
y, yErr := decodeCoordinate(item.Y)
if xErr == nil && yErr == nil && elliptic.P256().IsOnCurve(x, y) {
keys[item.KID] = &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}
}
}
if len(keys) == 0 {
return errors.New("SSF JWKS contains no ES256 keys")
}
v.keys, v.expiresAt = keys, time.Now().Add(5*time.Minute)
return nil
}
func (v *Verifier) fetchJSON(ctx context.Context, endpoint string, target any) error {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
request.Header.Set("Accept", "application/json")
response, err := v.client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return fmt.Errorf("SSF endpoint returned HTTP %d", response.StatusCode)
}
payload, err := io.ReadAll(io.LimitReader(response.Body, (1<<20)+1))
if err != nil || len(payload) > 1<<20 {
return errors.New("SSF endpoint response is too large")
}
if err := json.Unmarshal(payload, target); err != nil {
return errors.New("SSF endpoint returned invalid JSON")
}
return nil
}
func ssfConfigurationURL(issuer string) (string, error) {
parsed, err := url.Parse(issuer)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", errors.New("SSF issuer is invalid")
}
path := strings.TrimSuffix(parsed.EscapedPath(), "/")
parsed.Path, parsed.RawPath = "/.well-known/ssf-configuration"+path, ""
parsed.RawQuery, parsed.Fragment = "", ""
return parsed.String(), nil
}
func sameOrigin(candidate, issuer string) bool {
candidateURL, candidateErr := url.Parse(candidate)
issuerURL, issuerErr := url.Parse(issuer)
return candidateErr == nil && issuerErr == nil && candidateURL.Scheme == issuerURL.Scheme && strings.EqualFold(candidateURL.Host, issuerURL.Host) && candidateURL.User == nil && candidateURL.Fragment == ""
}
func absoluteHTTPURL(raw string) bool {
parsed, err := url.Parse(raw)
return err == nil && parsed.IsAbs() && parsed.Hostname() != "" && (parsed.Scheme == "https" || parsed.Scheme == "http") && parsed.User == nil && parsed.Fragment == ""
}
func decodeCoordinate(raw string) (*big.Int, error) {
value, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil || len(value) != 32 {
return nil, errors.New("invalid EC coordinate")
}
return new(big.Int).SetBytes(value), nil
}
func stringClaim(claims jwt.MapClaims, name string) string { return stringValue(claims[name]) }
func stringValue(value any) string {
result, _ := value.(string)
return result
}
func unixClaim(value any) time.Time {
switch typed := value.(type) {
case float64:
return time.Unix(int64(typed), 0).UTC()
case json.Number:
integer, err := typed.Int64()
if err == nil {
return time.Unix(integer, 0).UTC()
}
}
return time.Time{}
}
func validInitiator(value string) bool {
return value == "admin" || value == "user" || value == "policy" || value == "system"
}