Files
oci-portal/internal/service/federation.go
T
2026-07-22 16:51:23 +08:00

318 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) URLdata 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)), "<svg")
}
return false
}
// ConsoleSignOnRules 按优先级列出 OCI Console sign-on 策略的规则。
func (s *OciConfigService) ConsoleSignOnRules(ctx context.Context, id uint, domainID string) ([]oci.SignOnRuleInfo, error) {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return nil, err
}
return s.client.ListConsoleSignOnRules(ctx, cred, homeRegion, domainID)
}
// CreateMfaExemption 为指定 IdP 创建免 MFA 规则并置为最高优先级。
func (s *OciConfigService) CreateMfaExemption(ctx context.Context, id uint, domainID, idpID string) (oci.SignOnRuleInfo, error) {
if strings.TrimSpace(idpID) == "" {
return oci.SignOnRuleInfo{}, fmt.Errorf("create mfa exemption: identityProviderId is required")
}
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return oci.SignOnRuleInfo{}, err
}
name, err := s.exemptionRuleName(ctx, cred, homeRegion, domainID, idpID)
if err != nil {
return oci.SignOnRuleInfo{}, err
}
return s.client.CreateMfaExemptionRule(ctx, cred, homeRegion, domainID, idpID, name)
}
// exemptionRuleName 校验 IdP 存在并用其名称生成规则名。
func (s *OciConfigService) exemptionRuleName(ctx context.Context, cred oci.Credentials, region, domainID, idpID string) (string, error) {
idps, err := s.client.ListIdentityProviders(ctx, cred, region, domainID)
if err != nil {
return "", err
}
for _, idp := range idps {
if idp.ID == idpID {
return "skip-mfa-" + idp.Name, nil
}
}
return "", fmt.Errorf("create mfa exemption: identity provider %s not found", idpID)
}
// DeleteMfaExemption 删除免 MFA 规则并恢复其余规则优先级。
func (s *OciConfigService) DeleteMfaExemption(ctx context.Context, id uint, domainID, ruleID string) error {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return err
}
return s.client.DeleteMfaExemptionRule(ctx, cred, homeRegion, domainID, ruleID)
}