package service import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "io" "path" "strings" "unicode" "oci-portal/internal/oci" ) // idpIconMaxBytes 是 IdP 图标上传上限;idpIconExts 是允许的图片扩展名。 const ( idpIconMaxBytes = 1 << 20 idpIconRandomBytes = 16 ) var idpIconExts = map[string]bool{ ".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".svg": true, ".webp": true, ".ico": true, } // 图标校验错误哨兵:api 层据此映射 400 / 413 / 415,不落统一 500 边界。 var ( ErrIdpIconEmpty = errors.New("icon file is empty") ErrIdpIconTooLarge = errors.New("icon exceeds 1MB") ErrIdpIconBadType = errors.New("icon file type not allowed") ErrIdpIconBadName = errors.New("icon file name is invalid") ) // IdentityProviders 列出域内 SAML 身份提供者。 func (s *OciConfigService) IdentityProviders(ctx context.Context, id uint, domainID string) ([]oci.IdentityProviderInfo, error) { cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) if err != nil { return nil, err } return s.client.ListIdentityProviders(ctx, cred, homeRegion, domainID) } // CreateIdentityProvider 创建禁用态 SAML IdP,激活另走 activate。 // 映射与 JIT 字段缺省时填控制台默认值(normalizeIdpInput)。 func (s *OciConfigService) CreateIdentityProvider(ctx context.Context, id uint, domainID string, in oci.CreateIdpInput) (oci.IdentityProviderInfo, error) { if strings.TrimSpace(in.Name) == "" { return oci.IdentityProviderInfo{}, fmt.Errorf("create identity provider: name is required") } if !strings.Contains(in.Metadata, "EntityDescriptor") { return oci.IdentityProviderInfo{}, fmt.Errorf("create identity provider: metadata must be a SAML metadata XML") } // IDCS 校验 iconUrl 必须是 http(s) URL,data URI 会被 400 拒绝 if in.IconURL != "" && !strings.HasPrefix(in.IconURL, "http://") && !strings.HasPrefix(in.IconURL, "https://") { return oci.IdentityProviderInfo{}, fmt.Errorf("create identity provider: iconUrl 需为 http(s) 外链图片 URL") } cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) if err != nil { return oci.IdentityProviderInfo{}, err } in, err = s.enforceIdpDomainSettings(ctx, cred, homeRegion, domainID, normalizeIdpInput(in)) if err != nil { return oci.IdentityProviderInfo{}, err } return s.client.CreateSamlIdentityProvider(ctx, cred, homeRegion, domainID, in) } func (s *OciConfigService) enforceIdpDomainSettings(ctx context.Context, cred oci.Credentials, region, domainID string, in oci.CreateIdpInput) (oci.CreateIdpInput, error) { if !in.JitEnabled { return in, nil } setting, err := s.client.GetIdentitySetting(ctx, cred, region, domainID) if err != nil { return in, fmt.Errorf("get identity setting before create idp: %w", err) } if setting.PrimaryEmailRequired { in.JitMapEmail = true } return in, nil } // normalizeIdpInput 填充映射字段的控制台默认值:名称 ID 格式「无」、 // 目标域用户属性「用户名」(JIT 开关组合由 API 层的指针缺省决定)。 func normalizeIdpInput(in oci.CreateIdpInput) oci.CreateIdpInput { if in.NameIDFormat == "" { in.NameIDFormat = "saml-none" } if in.UserStoreAttribute == "" { in.UserStoreAttribute = "userName" } return in } // SetIdentityProviderEnabled 激活/停用 IdP 并同步登录页显示。 func (s *OciConfigService) SetIdentityProviderEnabled(ctx context.Context, id uint, domainID, idpID string, enabled bool) (oci.IdentityProviderInfo, error) { cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) if err != nil { return oci.IdentityProviderInfo{}, err } return s.client.SetIdentityProviderEnabled(ctx, cred, homeRegion, domainID, idpID, enabled) } // DeleteIdentityProvider 级联删除 IdP:先删关联的免 MFA 规则,再从登录页 // 移除、停用、删除 IdP 本体。 func (s *OciConfigService) DeleteIdentityProvider(ctx context.Context, id uint, domainID, idpID string) error { cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) if err != nil { return err } if err := s.deleteIdpExemptions(ctx, cred, homeRegion, domainID, idpID); err != nil { return err } return s.client.DeleteIdentityProvider(ctx, cred, homeRegion, domainID, idpID) } // deleteIdpExemptions 删除 sign-on 策略中引用目标 IdP 的免 MFA 规则。 func (s *OciConfigService) deleteIdpExemptions(ctx context.Context, cred oci.Credentials, region, domainID, idpID string) error { rules, err := s.client.ListConsoleSignOnRules(ctx, cred, region, domainID) if err != nil { return fmt.Errorf("list sign-on rules before delete idp: %w", err) } for _, r := range rules { if r.BuiltIn || !strings.Contains(r.ConditionValue, idpID) { continue } if err := s.client.DeleteMfaExemptionRule(ctx, cred, region, domainID, r.ID); err != nil { return fmt.Errorf("delete exemption rule %s: %w", r.Name, err) } } return nil } // DomainSamlMetadata 下载域的 SP SAML 元数据 XML(提供给 IdP 侧配置)。 func (s *OciConfigService) DomainSamlMetadata(ctx context.Context, id uint, domainID string) ([]byte, error) { cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) if err != nil { return nil, err } return s.client.DownloadDomainSamlMetadata(ctx, cred, homeRegion, domainID) } // UploadIdpIcon 上传 IdP 图标到身份域公共图片存储,返回公网地址与存储内文件名。 func (s *OciConfigService) UploadIdpIcon(ctx context.Context, id uint, domainID, fileName string, data []byte) (string, string, error) { return s.uploadIdpIcon(ctx, id, domainID, fileName, data, rand.Reader) } func (s *OciConfigService) uploadIdpIcon(ctx context.Context, id uint, domainID, fileName string, data []byte, random io.Reader) (string, string, error) { if len(data) == 0 { return "", "", fmt.Errorf("upload idp icon: %w", ErrIdpIconEmpty) } if len(data) > idpIconMaxBytes { return "", "", fmt.Errorf("upload idp icon: %w", ErrIdpIconTooLarge) } fileName, err := validateIdpIcon(fileName, data) if err != nil { return "", "", fmt.Errorf("upload idp icon: %w", err) } fileName, err = newIdpIconStorageName(fileName, random) if err != nil { return "", "", fmt.Errorf("upload idp icon: %w", err) } cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) if err != nil { return "", "", err } url, storedName, err := s.client.UploadDomainImage(ctx, cred, homeRegion, domainID, fileName, data) if err != nil { return "", "", fmt.Errorf("upload idp icon: %w", err) } return url, storedName, nil } func newIdpIconStorageName(fileName string, random io.Reader) (string, error) { token := make([]byte, idpIconRandomBytes) if _, err := io.ReadFull(random, token); err != nil { return "", fmt.Errorf("generate storage name: %w", err) } ext := strings.ToLower(path.Ext(fileName)) return "idp-icon-" + hex.EncodeToString(token) + ext, nil } // DeleteIdpIcon 按上传响应中的 fileName 删除身份域公开图片。 func (s *OciConfigService) DeleteIdpIcon(ctx context.Context, id uint, domainID, fileName string) error { if err := validateDomainImageFileName(fileName); err != nil { return fmt.Errorf("delete idp icon: %w", err) } cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) if err != nil { return err } if err := s.client.DeleteDomainImage(ctx, cred, homeRegion, domainID, fileName); err != nil { return fmt.Errorf("delete idp icon: %w", err) } return nil } func validateIdpIcon(input string, data []byte) (string, error) { fileName := path.Base(strings.ReplaceAll(input, "\\", "/")) if invalidIconName(fileName, 255) { return "", ErrIdpIconBadName } ext := strings.ToLower(path.Ext(fileName)) if !idpIconExts[ext] || !iconMagicOK(ext, data) { return "", fmt.Errorf("%w: %q", ErrIdpIconBadType, ext) } return fileName, nil } func validateDomainImageFileName(fileName string) error { if invalidIconName(fileName, 1024) || strings.Contains(fileName, "\\") { return ErrIdpIconBadName } clean := path.Clean(fileName) if clean != fileName || path.Dir(clean) != "images" || !validIdpIconStorageBase(path.Base(clean)) { return ErrIdpIconBadName } return nil } func validIdpIconStorageBase(fileName string) bool { ext := path.Ext(fileName) if ext == "" || ext != strings.ToLower(ext) || !idpIconExts[ext] { return false } stem := strings.TrimSuffix(fileName, ext) if !strings.HasPrefix(stem, "idp-icon-") { return false } token := strings.TrimPrefix(stem, "idp-icon-") if len(token) != hex.EncodedLen(idpIconRandomBytes) || token != strings.ToLower(token) { return false } _, err := hex.DecodeString(token) return err == nil } func invalidIconName(fileName string, maxBytes int) bool { return fileName == "" || fileName == "." || fileName == ".." || len(fileName) > maxBytes || strings.TrimSpace(fileName) != fileName || strings.IndexFunc(fileName, unicode.IsControl) >= 0 } // iconMagicOK 用文件头魔数轻量核对扩展名与内容大体一致。不做深度解码: // 图标由管理员为自己租户上传,存储与服务均在 Oracle 域名侧,传错内容只会 // 让自己登录页图标裂开,深度校验的收益承担不起其复杂度与误伤。 func iconMagicOK(ext string, data []byte) bool { has := func(prefix string) bool { return len(data) >= len(prefix) && string(data[:len(prefix)]) == prefix } switch ext { case ".png": return has("\x89PNG\r\n\x1a\n") case ".jpg", ".jpeg": return len(data) >= 3 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff case ".gif": return has("GIF87a") || has("GIF89a") case ".webp": return len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP" case ".ico": return len(data) >= 4 && data[0] == 0 && data[1] == 0 && data[2] == 1 && data[3] == 0 case ".svg": head := data if len(head) > 512 { head = head[:512] } return strings.Contains(strings.ToLower(string(head)), "