440 lines
13 KiB
Go
440 lines
13 KiB
Go
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
|
|
}
|