feat: 接入 SSF 实时会话撤销
This commit is contained in:
@@ -8,6 +8,8 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -16,50 +18,63 @@ const (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AppEnv string
|
||||
HTTPAddr string
|
||||
DatabaseURL string
|
||||
IdentityMode string
|
||||
JWTSecret string
|
||||
ServerMainBaseURL string
|
||||
ServerMainInternalToken string
|
||||
ServerMainInternalKey string
|
||||
ServerMainInternalSecret string
|
||||
OIDCEnabled bool
|
||||
OIDCIssuer string
|
||||
OIDCAudience string
|
||||
OIDCTenantID string
|
||||
OIDCRolePrefix string
|
||||
OIDCRequiredScopes []string
|
||||
OIDCJWKSCacheTTLSeconds int
|
||||
OIDCAcceptLegacyHS256 bool
|
||||
OIDCIntrospectionEnabled bool
|
||||
OIDCIntrospectionClientID string
|
||||
OIDCIntrospectionClientSecret string
|
||||
OIDCJITProvisioningEnabled bool
|
||||
OIDCGatewayTenantKey string
|
||||
OIDCBrowserSessionEnabled bool
|
||||
OIDCSessionCookieSecure bool
|
||||
OIDCClientID string
|
||||
OIDCRedirectURI string
|
||||
OIDCPostLogoutRedirectURI string
|
||||
OIDCSessionEncryptionKey string
|
||||
OIDCSessionIdleTTLSeconds int
|
||||
OIDCSessionAbsoluteTTLSeconds int
|
||||
OIDCSessionRefreshBeforeSeconds int
|
||||
PublicBaseURL string
|
||||
WebBaseURL string
|
||||
LocalGeneratedStorageDir string
|
||||
LocalUploadedStorageDir string
|
||||
LocalTempAssetTTLHours int
|
||||
TaskProgressCallbackEnabled bool
|
||||
TaskProgressCallbackURL string
|
||||
TaskProgressCallbackTimeoutMS string
|
||||
TaskProgressCallbackMaxAttempts string
|
||||
CORSAllowedOrigin string
|
||||
GlobalHTTPProxy string
|
||||
GlobalHTTPProxySource string
|
||||
LogLevel slog.Level
|
||||
AppEnv string
|
||||
HTTPAddr string
|
||||
DatabaseURL string
|
||||
IdentityMode string
|
||||
JWTSecret string
|
||||
ServerMainBaseURL string
|
||||
ServerMainInternalToken string
|
||||
ServerMainInternalKey string
|
||||
ServerMainInternalSecret string
|
||||
OIDCEnabled bool
|
||||
OIDCIssuer string
|
||||
OIDCAudience string
|
||||
OIDCTenantID string
|
||||
OIDCRolePrefix string
|
||||
OIDCRequiredScopes []string
|
||||
OIDCJWKSCacheTTLSeconds int
|
||||
OIDCAcceptLegacyHS256 bool
|
||||
OIDCIntrospectionEnabled bool
|
||||
OIDCIntrospectionClientID string
|
||||
OIDCIntrospectionClientSecret string
|
||||
OIDCSecurityEventsEnabled bool
|
||||
OIDCSecurityEventsTransmitterIssuer string
|
||||
OIDCSecurityEventsReceiverAudience string
|
||||
OIDCSecurityEventsBearerSecret string
|
||||
OIDCSecurityEventsBearerSecretNext string
|
||||
OIDCSecurityEventsPublicEndpoint string
|
||||
OIDCSecurityEventsStreamID string
|
||||
OIDCSecurityEventsManagementTokenURL string
|
||||
OIDCSecurityEventsManagementClientID string
|
||||
OIDCSecurityEventsManagementClientSecret string
|
||||
OIDCSecurityEventsHeartbeatIntervalSeconds int
|
||||
OIDCSecurityEventsStaleAfterSeconds int
|
||||
OIDCSecurityEventsClockSkewSeconds int
|
||||
OIDCJITProvisioningEnabled bool
|
||||
OIDCGatewayTenantKey string
|
||||
OIDCBrowserSessionEnabled bool
|
||||
OIDCSessionCookieSecure bool
|
||||
OIDCClientID string
|
||||
OIDCRedirectURI string
|
||||
OIDCPostLogoutRedirectURI string
|
||||
OIDCSessionEncryptionKey string
|
||||
OIDCSessionIdleTTLSeconds int
|
||||
OIDCSessionAbsoluteTTLSeconds int
|
||||
OIDCSessionRefreshBeforeSeconds int
|
||||
PublicBaseURL string
|
||||
WebBaseURL string
|
||||
LocalGeneratedStorageDir string
|
||||
LocalUploadedStorageDir string
|
||||
LocalTempAssetTTLHours int
|
||||
TaskProgressCallbackEnabled bool
|
||||
TaskProgressCallbackURL string
|
||||
TaskProgressCallbackTimeoutMS string
|
||||
TaskProgressCallbackMaxAttempts string
|
||||
CORSAllowedOrigin string
|
||||
GlobalHTTPProxy string
|
||||
GlobalHTTPProxySource string
|
||||
LogLevel slog.Level
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
@@ -75,23 +90,36 @@ func Load() Config {
|
||||
env("SERVER_MAIN_BASE_URL", "http://localhost:3000"),
|
||||
"/",
|
||||
),
|
||||
ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""),
|
||||
ServerMainInternalKey: env("SERVER_MAIN_INTERNAL_KEY", "gateway"),
|
||||
ServerMainInternalSecret: env("SERVER_MAIN_INTERNAL_SECRET", env("SERVER_MAIN_INTERNAL_TOKEN", "")),
|
||||
OIDCEnabled: env("OIDC_ENABLED", "false") == "true",
|
||||
OIDCIssuer: strings.TrimRight(env("OIDC_ISSUER", ""), "/"),
|
||||
OIDCAudience: env("OIDC_AUDIENCE", ""),
|
||||
OIDCTenantID: env("OIDC_TENANT_ID", ""),
|
||||
OIDCRolePrefix: env("OIDC_ROLE_PREFIX", "gateway."),
|
||||
OIDCRequiredScopes: splitCSV(env("OIDC_REQUIRED_SCOPES", "gateway.access")),
|
||||
OIDCJWKSCacheTTLSeconds: envInt("OIDC_JWKS_CACHE_TTL_SECONDS", 300),
|
||||
OIDCAcceptLegacyHS256: env("OIDC_ACCEPT_LEGACY_HS256", "true") == "true",
|
||||
OIDCIntrospectionEnabled: env("OIDC_INTROSPECTION_ENABLED", "false") == "true",
|
||||
OIDCIntrospectionClientID: env("OIDC_INTROSPECTION_CLIENT_ID", ""),
|
||||
OIDCIntrospectionClientSecret: env("OIDC_INTROSPECTION_CLIENT_SECRET", ""),
|
||||
OIDCJITProvisioningEnabled: env("OIDC_JIT_PROVISIONING_ENABLED", "false") == "true",
|
||||
OIDCGatewayTenantKey: env("OIDC_GATEWAY_TENANT_KEY", ""),
|
||||
OIDCBrowserSessionEnabled: env("OIDC_BROWSER_SESSION_ENABLED", "true") == "true",
|
||||
ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""),
|
||||
ServerMainInternalKey: env("SERVER_MAIN_INTERNAL_KEY", "gateway"),
|
||||
ServerMainInternalSecret: env("SERVER_MAIN_INTERNAL_SECRET", env("SERVER_MAIN_INTERNAL_TOKEN", "")),
|
||||
OIDCEnabled: env("OIDC_ENABLED", "false") == "true",
|
||||
OIDCIssuer: strings.TrimRight(env("OIDC_ISSUER", ""), "/"),
|
||||
OIDCAudience: env("OIDC_AUDIENCE", ""),
|
||||
OIDCTenantID: env("OIDC_TENANT_ID", ""),
|
||||
OIDCRolePrefix: env("OIDC_ROLE_PREFIX", "gateway."),
|
||||
OIDCRequiredScopes: splitCSV(env("OIDC_REQUIRED_SCOPES", "gateway.access")),
|
||||
OIDCJWKSCacheTTLSeconds: envInt("OIDC_JWKS_CACHE_TTL_SECONDS", 300),
|
||||
OIDCAcceptLegacyHS256: env("OIDC_ACCEPT_LEGACY_HS256", "true") == "true",
|
||||
OIDCIntrospectionEnabled: env("OIDC_INTROSPECTION_ENABLED", "false") == "true",
|
||||
OIDCIntrospectionClientID: env("OIDC_INTROSPECTION_CLIENT_ID", ""),
|
||||
OIDCIntrospectionClientSecret: env("OIDC_INTROSPECTION_CLIENT_SECRET", ""),
|
||||
OIDCSecurityEventsEnabled: env("OIDC_SECURITY_EVENTS_ENABLED", "false") == "true",
|
||||
OIDCSecurityEventsTransmitterIssuer: strings.TrimRight(env("OIDC_SECURITY_EVENTS_TRANSMITTER_ISSUER", ""), "/"),
|
||||
OIDCSecurityEventsReceiverAudience: env("OIDC_SECURITY_EVENTS_RECEIVER_AUDIENCE", ""),
|
||||
OIDCSecurityEventsBearerSecret: env("OIDC_SECURITY_EVENTS_BEARER_SECRET", ""),
|
||||
OIDCSecurityEventsBearerSecretNext: env("OIDC_SECURITY_EVENTS_BEARER_SECRET_NEXT", ""),
|
||||
OIDCSecurityEventsPublicEndpoint: env("OIDC_SECURITY_EVENTS_PUBLIC_ENDPOINT", ""),
|
||||
OIDCSecurityEventsStreamID: env("OIDC_SECURITY_EVENTS_STREAM_ID", ""),
|
||||
OIDCSecurityEventsManagementTokenURL: env("OIDC_SECURITY_EVENTS_MANAGEMENT_TOKEN_URL", ""),
|
||||
OIDCSecurityEventsManagementClientID: env("OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_ID", ""),
|
||||
OIDCSecurityEventsManagementClientSecret: env("OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_SECRET", ""),
|
||||
OIDCSecurityEventsHeartbeatIntervalSeconds: envInt("OIDC_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS", 60),
|
||||
OIDCSecurityEventsStaleAfterSeconds: envInt("OIDC_SECURITY_EVENTS_STALE_AFTER_SECONDS", 180),
|
||||
OIDCSecurityEventsClockSkewSeconds: envInt("OIDC_SECURITY_EVENTS_CLOCK_SKEW_SECONDS", 60),
|
||||
OIDCJITProvisioningEnabled: env("OIDC_JIT_PROVISIONING_ENABLED", "false") == "true",
|
||||
OIDCGatewayTenantKey: env("OIDC_GATEWAY_TENANT_KEY", ""),
|
||||
OIDCBrowserSessionEnabled: env("OIDC_BROWSER_SESSION_ENABLED", "true") == "true",
|
||||
OIDCSessionCookieSecure: env("OIDC_SESSION_COOKIE_SECURE",
|
||||
strconv.FormatBool(!isLocalEnvironment(appEnv)),
|
||||
) == "true",
|
||||
@@ -157,9 +185,59 @@ func (c Config) Validate() error {
|
||||
}
|
||||
}
|
||||
}
|
||||
if c.OIDCSecurityEventsEnabled {
|
||||
if !c.OIDCEnabled {
|
||||
return errors.New("OIDC_ENABLED must be true when OIDC security events are enabled")
|
||||
}
|
||||
if !validServiceURL(c.OIDCSecurityEventsTransmitterIssuer, c.AppEnv) ||
|
||||
!validSecurityEventReceiverURL(c.OIDCSecurityEventsPublicEndpoint, c.AppEnv) ||
|
||||
!validServiceURL(c.OIDCSecurityEventsManagementTokenURL, c.AppEnv) {
|
||||
return errors.New("OIDC security event issuer, public endpoint, and management token URL must be HTTPS URLs")
|
||||
}
|
||||
if _, err := uuid.Parse(c.OIDCTenantID); err != nil {
|
||||
return errors.New("OIDC_TENANT_ID must be a UUID when OIDC security events are enabled")
|
||||
}
|
||||
applicationID := strings.TrimPrefix(c.OIDCSecurityEventsReceiverAudience, "urn:easyai:ssf:receiver:")
|
||||
if applicationID == c.OIDCSecurityEventsReceiverAudience {
|
||||
return errors.New("OIDC security event receiver audience and stream ID are required")
|
||||
}
|
||||
if _, err := uuid.Parse(applicationID); err != nil {
|
||||
return errors.New("OIDC security event receiver audience must contain an application UUID")
|
||||
}
|
||||
if _, err := uuid.Parse(c.OIDCSecurityEventsStreamID); err != nil {
|
||||
return errors.New("OIDC security event stream ID must be a UUID")
|
||||
}
|
||||
if !validBearerSecret(c.OIDCSecurityEventsBearerSecret) ||
|
||||
c.OIDCSecurityEventsBearerSecretNext != "" && !validBearerSecret(c.OIDCSecurityEventsBearerSecretNext) {
|
||||
return errors.New("OIDC security event bearer secrets must be 16-4096 printable non-space ASCII characters")
|
||||
}
|
||||
if c.OIDCSecurityEventsManagementClientID == "" || c.OIDCSecurityEventsManagementClientSecret == "" {
|
||||
return errors.New("OIDC security event management machine client credentials are required")
|
||||
}
|
||||
if c.OIDCIntrospectionClientID == "" || c.OIDCIntrospectionClientSecret == "" {
|
||||
return errors.New("RFC 7662 client credentials are required when OIDC security events are enabled")
|
||||
}
|
||||
if c.OIDCSecurityEventsHeartbeatIntervalSeconds <= 0 ||
|
||||
c.OIDCSecurityEventsStaleAfterSeconds < 2*c.OIDCSecurityEventsHeartbeatIntervalSeconds ||
|
||||
c.OIDCSecurityEventsClockSkewSeconds < 0 || c.OIDCSecurityEventsClockSkewSeconds > 300 {
|
||||
return errors.New("OIDC security event heartbeat, stale threshold, or clock skew is invalid")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validBearerSecret(value string) bool {
|
||||
if len(value) < 16 || len(value) > 4096 {
|
||||
return false
|
||||
}
|
||||
for _, character := range []byte(value) {
|
||||
if character < 0x21 || character > 0x7e {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c Config) OIDCSessionEncryptionKeyBytes() ([]byte, error) {
|
||||
raw := strings.TrimSpace(c.OIDCSessionEncryptionKey)
|
||||
for _, encoding := range []*base64.Encoding{base64.RawStdEncoding, base64.StdEncoding, base64.RawURLEncoding, base64.URLEncoding} {
|
||||
@@ -182,6 +260,25 @@ func validPublicRedirectURL(raw string) bool {
|
||||
return parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1")
|
||||
}
|
||||
|
||||
func validServiceURL(raw, appEnv string) bool {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" {
|
||||
return false
|
||||
}
|
||||
if parsed.Scheme == "https" {
|
||||
return true
|
||||
}
|
||||
return isLocalEnvironment(appEnv) && parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1")
|
||||
}
|
||||
|
||||
func validSecurityEventReceiverURL(raw, appEnv string) bool {
|
||||
if !validServiceURL(raw, appEnv) {
|
||||
return false
|
||||
}
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
return err == nil && parsed.EscapedPath() == "/api/v1/security-events/ssf" && parsed.RawQuery == ""
|
||||
}
|
||||
|
||||
func isLocalEnvironment(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "development", "dev", "local", "test":
|
||||
|
||||
@@ -136,3 +136,34 @@ func TestValidateRejectsOfflineAccessForBrowserSession(t *testing.T) {
|
||||
t.Fatalf("Validate() error = %v, want offline_access rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityEventsAreOptionalButFailFastWhenPartiallyConfigured(t *testing.T) {
|
||||
if err := (Config{}).Validate(); err != nil {
|
||||
t.Fatalf("disabled security events changed existing config: %v", err)
|
||||
}
|
||||
cfg := Config{AppEnv: "test", OIDCEnabled: true, OIDCSecurityEventsEnabled: true}
|
||||
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "issuer") {
|
||||
t.Fatalf("partial security event config error = %v", err)
|
||||
}
|
||||
cfg.OIDCSecurityEventsTransmitterIssuer = "http://localhost:8080/ssf"
|
||||
cfg.OIDCSecurityEventsPublicEndpoint = "http://localhost:8088/api/v1/security-events/ssf"
|
||||
cfg.OIDCSecurityEventsManagementTokenURL = "http://localhost:8080/oauth/token"
|
||||
cfg.OIDCTenantID = "00000000-0000-4000-8000-000000000003"
|
||||
cfg.OIDCSecurityEventsReceiverAudience = "urn:easyai:ssf:receiver:00000000-0000-4000-8000-000000000002"
|
||||
cfg.OIDCSecurityEventsStreamID = "00000000-0000-4000-8000-000000000001"
|
||||
cfg.OIDCSecurityEventsBearerSecret = "receiver-secret-at-least-16"
|
||||
cfg.OIDCSecurityEventsManagementClientID = "gateway-ssf"
|
||||
cfg.OIDCSecurityEventsManagementClientSecret = "management-secret"
|
||||
cfg.OIDCIntrospectionClientID = "gateway-introspection"
|
||||
cfg.OIDCIntrospectionClientSecret = "introspection-secret"
|
||||
cfg.OIDCSecurityEventsHeartbeatIntervalSeconds = 60
|
||||
cfg.OIDCSecurityEventsStaleAfterSeconds = 180
|
||||
cfg.OIDCSecurityEventsClockSkewSeconds = 60
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("valid security event config: %v", err)
|
||||
}
|
||||
cfg.OIDCSecurityEventsPublicEndpoint = "http://localhost:8088/wrong-path"
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Fatal("non-canonical security event receiver path was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user