@@ -2,12 +2,36 @@ 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)
|
||||
@@ -34,7 +58,25 @@ func (s *OciConfigService) CreateIdentityProvider(ctx context.Context, id uint,
|
||||
if err != nil {
|
||||
return oci.IdentityProviderInfo{}, err
|
||||
}
|
||||
return s.client.CreateSamlIdentityProvider(ctx, cred, homeRegion, domainID, normalizeIdpInput(in))
|
||||
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 格式「无」、
|
||||
@@ -97,6 +139,135 @@ func (s *OciConfigService) DomainSamlMetadata(ctx context.Context, id uint, doma
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user