package service import ( "context" "encoding/base64" "errors" "fmt" "net/url" "strconv" "strings" "wucher/internal/config" mastersettings "wucher/internal/domain/master_settings" ) type MasterSettingsService struct { repo mastersettings.Repository protector SecretProtector } type MasterSettingsServiceDependencies struct { Protector SecretProtector } func NewMasterSettingsService(repo mastersettings.Repository, deps MasterSettingsServiceDependencies) *MasterSettingsService { return &MasterSettingsService{ repo: repo, protector: deps.Protector, } } func (s *MasterSettingsService) SetSecretProtector(protector SecretProtector) *MasterSettingsService { if s == nil { return nil } s.protector = protector return s } func (s *MasterSettingsService) Create(ctx context.Context, row *mastersettings.MasterSettings) error { return s.repo.Create(ctx, row) } func (s *MasterSettingsService) Update(ctx context.Context, row *mastersettings.MasterSettings) error { return s.repo.Update(ctx, row) } func (s *MasterSettingsService) Delete(ctx context.Context, id []byte, deletedBy []byte) error { return s.repo.Delete(ctx, id, deletedBy) } func (s *MasterSettingsService) GetByID(ctx context.Context, id []byte) (*mastersettings.MasterSettings, error) { return s.repo.GetByID(ctx, id) } func (s *MasterSettingsService) List(ctx context.Context, filter, sort string, limit, offset int) ([]mastersettings.MasterSettings, int64, error) { return s.repo.List(ctx, filter, normalizeMasterSettingsSort(sort), limit, offset) } func (s *MasterSettingsService) UpsertMicrosoftEntraConfig( ctx context.Context, input mastersettings.MicrosoftEntraConfigInput, actor []byte, ) (*mastersettings.MicrosoftEntraConfigView, error) { normalized, err := normalizeMicrosoftEntraConfigInput(input) if err != nil { return nil, err } rows := []mastersettings.MasterSettingValue{ { SettingKey: mastersettings.SettingMicrosoftEntraTenantID, Value: normalized.TenantID, CreatedBy: actor, UpdatedBy: actor, IsEncrypted: false, }, { SettingKey: mastersettings.SettingMicrosoftEntraClientID, Value: normalized.ClientID, CreatedBy: actor, UpdatedBy: actor, IsEncrypted: false, }, { SettingKey: mastersettings.SettingMicrosoftEntraRedirectURL, Value: normalized.RedirectURL, CreatedBy: actor, UpdatedBy: actor, IsEncrypted: false, }, { SettingKey: mastersettings.SettingMicrosoftEntraAuthority, Value: normalized.Authority, CreatedBy: actor, UpdatedBy: actor, IsEncrypted: false, }, { SettingKey: mastersettings.SettingMicrosoftEntraScopes, Value: normalized.Scopes, CreatedBy: actor, UpdatedBy: actor, IsEncrypted: false, }, } if normalized.ClientSecret != "" { if s.protector == nil { return nil, errors.New("secret protector not configured") } encryptedSecret, err := s.protector.Encrypt([]byte(normalized.ClientSecret)) if err != nil { return nil, fmt.Errorf("encrypt MS_ENTRA_CLIENT_SECRET: %w", err) } encodedSecret := base64.StdEncoding.EncodeToString(encryptedSecret) rows = append(rows, mastersettings.MasterSettingValue{ SettingKey: mastersettings.SettingMicrosoftEntraClientSecret, Value: encodedSecret, CreatedBy: actor, UpdatedBy: actor, IsEncrypted: true, }) } else { existingSecretRows, err := s.repo.GetSettingValuesByKeys(ctx, []string{mastersettings.SettingMicrosoftEntraClientSecret}) if err != nil { return nil, err } _, _, existingSecret, _, _, _, _ := extractMicrosoftEntraValues(existingSecretRows) if strings.TrimSpace(existingSecret) == "" { return nil, errors.New("MS_ENTRA_CLIENT_SECRET is required for initial setup") } } if err := s.repo.UpsertSettingValues(ctx, rows); err != nil { return nil, err } return s.GetMicrosoftEntraConfig(ctx) } func (s *MasterSettingsService) GetMicrosoftEntraConfig(ctx context.Context) (*mastersettings.MicrosoftEntraConfigView, error) { rows, err := s.repo.GetSettingValuesByKeys(ctx, mastersettings.MicrosoftEntraSettingKeys) if err != nil { return nil, err } tenant, clientID, secret, redirectURL, authority, scopes, encrypted := extractMicrosoftEntraValues(rows) // UI view supports partial config so non-sensitive bootstrapped keys can be rendered. // Runtime login flow still uses LoadMicrosoftEntraRuntimeConfig, which remains strict. if redirectURL == "" || authority == "" || scopes == "" { return nil, nil } secretMasked := "" if secret != "" && encrypted { secretMasked = "********" } else if secret != "" { secretMasked = maskMicrosoftSecret(secret) } return &mastersettings.MicrosoftEntraConfigView{ TenantID: tenant, ClientID: clientID, ClientSecretMasked: secretMasked, RedirectURL: redirectURL, Authority: authority, Scopes: scopes, }, nil } func (s *MasterSettingsService) LoadMicrosoftEntraRuntimeConfig(ctx context.Context) (config.MicrosoftSSOConfig, error) { rows, err := s.repo.GetSettingValuesByKeys(ctx, mastersettings.MicrosoftEntraSettingKeys) if err != nil { return config.MicrosoftSSOConfig{}, err } tenant, clientID, secret, redirectURL, authority, scopes, encrypted := extractMicrosoftEntraValues(rows) if tenant == "" || clientID == "" || secret == "" || redirectURL == "" || authority == "" || scopes == "" { return config.MicrosoftSSOConfig{}, errors.New("sso not configured") } plainSecret := secret if encrypted { if s.protector == nil { return config.MicrosoftSSOConfig{}, errors.New("secret protector not configured") } cipherBytes, err := base64.StdEncoding.DecodeString(secret) if err != nil { return config.MicrosoftSSOConfig{}, errors.New("invalid encrypted MS_ENTRA_CLIENT_SECRET") } plainBytes, err := s.protector.Decrypt(cipherBytes) if err != nil { return config.MicrosoftSSOConfig{}, errors.New("failed to decrypt MS_ENTRA_CLIENT_SECRET") } plainSecret = strings.TrimSpace(string(plainBytes)) } if plainSecret == "" { return config.MicrosoftSSOConfig{}, errors.New("sso not configured") } return config.MicrosoftSSOConfig{ TenantID: tenant, ClientID: clientID, ClientSecret: plainSecret, RedirectURL: redirectURL, Authority: authority, Scopes: splitCSV(scopes), }, nil } func (s *MasterSettingsService) IsLoginEmailOTPEnabled(ctx context.Context) (bool, error) { if s == nil || s.repo == nil { return true, nil } rows, err := s.repo.GetSettingValuesByKeys(ctx, []string{mastersettings.SettingAuthLoginEmailOTPEnabled}) if err != nil { return true, err } if len(rows) == 0 { return true, nil } raw := strings.TrimSpace(rows[0].Value) if raw == "" { return true, nil } enabled, parseErr := strconv.ParseBool(raw) if parseErr != nil { return true, nil } return enabled, nil } // SeedMicrosoftEntraConfigIfMissing backfills missing non-sensitive SSO setting keys from app config. // Existing non-empty values are preserved. func (s *MasterSettingsService) SeedMicrosoftEntraConfigIfMissing( ctx context.Context, cfg config.MicrosoftSSOConfig, ) error { if s == nil || s.repo == nil { return nil } rows, err := s.repo.GetSettingValuesByKeys(ctx, mastersettings.MicrosoftEntraSettingKeys) if err != nil { return err } existing := make(map[string]mastersettings.MasterSettingValue, len(rows)) for i := range rows { key := strings.TrimSpace(rows[i].SettingKey) if key == "" { continue } existing[key] = rows[i] } isMissing := func(key string) bool { row, ok := existing[key] if !ok { return true } return strings.TrimSpace(row.Value) == "" } normalized, err := normalizeMicrosoftEntraConfigInput(mastersettings.MicrosoftEntraConfigInput{ // Only validate and seed non-sensitive fields. // Tenant ID, Client ID, and Client Secret are intentionally excluded. TenantID: "placeholder", ClientID: "placeholder", ClientSecret: "placeholder", RedirectURL: cfg.RedirectURL, Authority: cfg.Authority, Scopes: strings.Join(cfg.Scopes, ","), }) if err != nil { return err } insertRows := make([]mastersettings.MasterSettingValue, 0, 3) if isMissing(mastersettings.SettingMicrosoftEntraRedirectURL) { insertRows = append(insertRows, mastersettings.MasterSettingValue{ SettingKey: mastersettings.SettingMicrosoftEntraRedirectURL, Value: normalized.RedirectURL, IsEncrypted: false, }) } if isMissing(mastersettings.SettingMicrosoftEntraAuthority) { insertRows = append(insertRows, mastersettings.MasterSettingValue{ SettingKey: mastersettings.SettingMicrosoftEntraAuthority, Value: normalized.Authority, IsEncrypted: false, }) } if isMissing(mastersettings.SettingMicrosoftEntraScopes) { insertRows = append(insertRows, mastersettings.MasterSettingValue{ SettingKey: mastersettings.SettingMicrosoftEntraScopes, Value: normalized.Scopes, IsEncrypted: false, }) } if len(insertRows) == 0 { return nil } return s.repo.UpsertSettingValues(ctx, insertRows) } func normalizeMasterSettingsSort(sort string) string { return normalizeSortByRules(sort, sortField("company_name"), sortField("title"), sortField("subtitle"), sortField("logo_url"), sortField("logo_file_id", "logo_file_uuid"), sortField("created_at"), sortField("updated_at"), ) } func normalizeMicrosoftEntraConfigInput(input mastersettings.MicrosoftEntraConfigInput) (mastersettings.MicrosoftEntraConfigInput, error) { normalized := mastersettings.MicrosoftEntraConfigInput{ TenantID: strings.TrimSpace(input.TenantID), ClientID: strings.TrimSpace(input.ClientID), ClientSecret: strings.TrimSpace(input.ClientSecret), RedirectURL: strings.TrimSpace(input.RedirectURL), Authority: strings.TrimSpace(input.Authority), Scopes: normalizeCSV(input.Scopes), } if normalized.TenantID == "" { return mastersettings.MicrosoftEntraConfigInput{}, errors.New("MS_ENTRA_TENANT_ID is required") } if normalized.ClientID == "" { return mastersettings.MicrosoftEntraConfigInput{}, errors.New("MS_ENTRA_CLIENT_ID is required") } if normalized.RedirectURL == "" { return mastersettings.MicrosoftEntraConfigInput{}, errors.New("MS_ENTRA_REDIRECT_URL is required") } if normalized.Authority == "" { return mastersettings.MicrosoftEntraConfigInput{}, errors.New("MS_ENTRA_AUTHORITY is required") } if normalized.Scopes == "" { return mastersettings.MicrosoftEntraConfigInput{}, errors.New("MS_ENTRA_SCOPES is required") } if !isValidHTTPURL(normalized.RedirectURL) { return mastersettings.MicrosoftEntraConfigInput{}, errors.New("MS_ENTRA_REDIRECT_URL must be a valid absolute URL") } if !isValidAuthorityURL(normalized.Authority) { return mastersettings.MicrosoftEntraConfigInput{}, errors.New("MS_ENTRA_AUTHORITY must be a valid absolute URL with scheme") } normalized.Authority = ensureTrailingSlash(normalized.Authority) return normalized, nil } func normalizeCSV(raw string) string { parts := strings.Split(raw, ",") items := make([]string, 0, len(parts)) seen := make(map[string]struct{}, len(parts)) for _, part := range parts { item := strings.TrimSpace(part) if item == "" { continue } if _, ok := seen[item]; ok { continue } seen[item] = struct{}{} items = append(items, item) } return strings.Join(items, ",") } func isValidHTTPURL(raw string) bool { u, err := url.Parse(raw) if err != nil { return false } if u == nil || !u.IsAbs() || strings.TrimSpace(u.Host) == "" { return false } switch strings.ToLower(strings.TrimSpace(u.Scheme)) { case "http", "https": return true default: return false } } func isValidAuthorityURL(raw string) bool { u, err := url.Parse(raw) if err != nil { return false } return u != nil && u.IsAbs() && strings.TrimSpace(u.Host) != "" && strings.TrimSpace(u.Scheme) != "" } func ensureTrailingSlash(raw string) string { if strings.HasSuffix(raw, "/") { return raw } return raw + "/" } func maskMicrosoftSecret(secret string) string { secret = strings.TrimSpace(secret) if secret == "" { return "" } if len(secret) <= 4 { return "****" } return strings.Repeat("*", len(secret)-4) + secret[len(secret)-4:] } func extractMicrosoftEntraValues(rows []mastersettings.MasterSettingValue) (tenant, clientID, secret, redirectURL, authority, scopes string, encrypted bool) { if len(rows) == 0 { return "", "", "", "", "", "", false } values := make(map[string]mastersettings.MasterSettingValue, len(rows)) for i := range rows { values[rows[i].SettingKey] = rows[i] } tenant = strings.TrimSpace(values[mastersettings.SettingMicrosoftEntraTenantID].Value) clientID = strings.TrimSpace(values[mastersettings.SettingMicrosoftEntraClientID].Value) secret = strings.TrimSpace(values[mastersettings.SettingMicrosoftEntraClientSecret].Value) redirectURL = strings.TrimSpace(values[mastersettings.SettingMicrosoftEntraRedirectURL].Value) authority = strings.TrimSpace(values[mastersettings.SettingMicrosoftEntraAuthority].Value) scopes = strings.TrimSpace(values[mastersettings.SettingMicrosoftEntraScopes].Value) encrypted = values[mastersettings.SettingMicrosoftEntraClientSecret].IsEncrypted return tenant, clientID, secret, redirectURL, authority, scopes, encrypted } func splitCSV(raw string) []string { parts := strings.Split(raw, ",") items := make([]string, 0, len(parts)) for _, part := range parts { item := strings.TrimSpace(part) if item == "" { continue } items = append(items, item) } return items }