Files
fm_be/internal/service/master_settings_service.go
2026-07-16 22:16:45 +07:00

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
}