feat: 接入 SSF 实时会话撤销

This commit is contained in:
2026-07-14 17:06:41 +08:00
parent 30ad0e9f2c
commit f30aaeb2d4
28 changed files with 2597 additions and 91 deletions
+74 -15
View File
@@ -5,6 +5,7 @@ import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
@@ -24,16 +25,31 @@ const maxOIDCResponseBytes = 1 << 20
const defaultOIDCHTTPTimeout = 10 * time.Second
type OIDCConfig struct {
Issuer string
Audience string
TenantID string
RolePrefix string
RequiredScopes []string
JWKSCacheTTL time.Duration
IntrospectionEnabled bool
IntrospectionClientID string
IntrospectionClientSecret string
HTTPClient *http.Client
Issuer string
Audience string
TenantID string
RolePrefix string
RequiredScopes []string
JWKSCacheTTL time.Duration
IntrospectionEnabled bool
IntrospectionClientID string
IntrospectionClientSecret string
HTTPClient *http.Client
SecurityEventEvaluator func(context.Context, OIDCSecurityEventIdentity) (OIDCSecurityEventEvaluation, error)
IntrospectionObserver func(string)
JWKSRefreshFailureObserver func()
}
type OIDCSecurityEventIdentity struct {
Issuer string
TenantID string
Subject string
IssuedAt time.Time
}
type OIDCSecurityEventEvaluation struct {
Revoked bool
RequireIntrospection bool
}
type OIDCVerifier struct {
@@ -75,7 +91,7 @@ func NewOIDCVerifier(config OIDCConfig) (*OIDCVerifier, error) {
if err := validatePublicURL(config.Issuer); err != nil || config.Audience == "" || config.TenantID == "" || config.RolePrefix == "" {
return nil, errors.New("issuer, audience, tenant and role prefix are required")
}
if config.IntrospectionEnabled && (strings.TrimSpace(config.IntrospectionClientID) == "" || config.IntrospectionClientSecret == "") {
if (config.IntrospectionEnabled || config.SecurityEventEvaluator != nil) && (strings.TrimSpace(config.IntrospectionClientID) == "" || config.IntrospectionClientSecret == "") {
return nil, errors.New("introspection client credentials are required when introspection is enabled")
}
if config.JWKSCacheTTL <= 0 {
@@ -83,9 +99,12 @@ func NewOIDCVerifier(config OIDCConfig) (*OIDCVerifier, error) {
}
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: defaultOIDCHTTPTimeout, CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
}}
}, Transport: transport}
}
return &OIDCVerifier{config: config, client: client, keys: map[string]any{}}, nil
}
@@ -125,6 +144,10 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
if _, ok := numericDateClaim(claims["nbf"]); !ok {
return nil, oidcUnauthorized("nbf is missing", nil)
}
issuedAt, hasIssuedAt := numericDateClaim(claims["iat"])
if v.config.SecurityEventEvaluator != nil && !hasIssuedAt {
return nil, oidcUnauthorized("iat is required when security events are enabled", nil)
}
scopes := scopeClaims(claims)
if !containsAll(scopes, v.config.RequiredScopes) {
return nil, oidcUnauthorized("required scope is missing", nil)
@@ -136,7 +159,26 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
if len(roles) == 0 {
return nil, oidcUnauthorized("mapped role is missing", nil)
}
if v.config.IntrospectionEnabled {
if v.config.SecurityEventEvaluator != nil {
evaluation, evaluateErr := v.config.SecurityEventEvaluator(ctx, OIDCSecurityEventIdentity{
Issuer: v.config.Issuer, TenantID: v.config.TenantID, Subject: stringClaim(claims, "sub"), IssuedAt: issuedAt,
})
if evaluateErr != nil {
return nil, NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SECURITY_EVENT_STATE_UNAVAILABLE", "认证撤销状态暂时不可用")
}
if evaluation.Revoked {
return nil, oidcUnauthorized("token was issued before the revocation watermark", nil)
}
if evaluation.RequireIntrospection {
active, introspectionErr := v.introspect(ctx, raw)
if introspectionErr != nil {
return nil, NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_INTROSPECTION_UNAVAILABLE", "认证中心内省暂时不可用")
}
if !active {
return nil, oidcUnauthorized("token is inactive", nil)
}
}
} else if v.config.IntrospectionEnabled {
active, err := v.introspect(ctx, raw)
if err != nil || !active {
return nil, oidcUnauthorized("token is inactive", err)
@@ -148,7 +190,7 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
}
return &User{
ID: stringClaim(claims, "sub"), Username: username, Roles: roles,
TenantID: v.config.TenantID, Source: "oidc", TokenExpiresAt: expiresAt,
TenantID: v.config.TenantID, Source: "oidc", TokenExpiresAt: expiresAt, TokenIssuedAt: issuedAt, Issuer: v.config.Issuer,
}, nil
}
@@ -161,6 +203,9 @@ func oidcUnauthorized(reason string, cause error) error {
func (v *OIDCVerifier) key(ctx context.Context, kid string) (any, error) {
if err := v.refresh(ctx, false); err != nil {
if v.config.JWKSRefreshFailureObserver != nil {
v.config.JWKSRefreshFailureObserver()
}
return nil, err
}
v.mu.Lock()
@@ -170,6 +215,9 @@ func (v *OIDCVerifier) key(ctx context.Context, kid string) (any, error) {
return key, nil
}
if err := v.refresh(ctx, true); err != nil {
if v.config.JWKSRefreshFailureObserver != nil {
v.config.JWKSRefreshFailureObserver()
}
return nil, err
}
v.mu.Lock()
@@ -195,7 +243,7 @@ func (v *OIDCVerifier) refresh(ctx context.Context, force bool) error {
if discovery.Issuer != v.config.Issuer || validatePublicURL(discovery.JWKSURI) != nil {
return errors.New("OIDC discovery metadata is invalid")
}
if v.config.IntrospectionEnabled && validatePublicURL(discovery.IntrospectionEndpoint) != nil {
if (v.config.IntrospectionEnabled || v.config.SecurityEventEvaluator != nil) && validatePublicURL(discovery.IntrospectionEndpoint) != nil {
return errors.New("OIDC introspection metadata is invalid")
}
var set jsonWebKeySet
@@ -222,6 +270,12 @@ func (v *OIDCVerifier) refresh(ctx context.Context, force bool) error {
}
func (v *OIDCVerifier) introspect(ctx context.Context, raw string) (bool, error) {
outcome := "failed"
defer func() {
if v.config.IntrospectionObserver != nil {
v.config.IntrospectionObserver(outcome)
}
}()
v.mu.Lock()
endpoint := v.introspectionEndpoint
v.mu.Unlock()
@@ -252,6 +306,11 @@ func (v *OIDCVerifier) introspect(ctx context.Context, raw string) (bool, error)
if err := decoder.Decode(&result); err != nil {
return false, errors.New("OIDC introspection response is invalid")
}
if result.Active {
outcome = "active"
} else {
outcome = "inactive"
}
return result.Active, nil
}