init push
This commit is contained in:
439
internal/service/master_settings_service.go
Normal file
439
internal/service/master_settings_service.go
Normal file
@@ -0,0 +1,439 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user