diff --git a/.env b/.env index ea7f1de..1f764ba 100644 --- a/.env +++ b/.env @@ -8,7 +8,7 @@ DB_MAX_OPEN_CONNS=25 DB_MAX_IDLE_CONNS=10 DB_CONN_MAX_LIFETIME=30m #Redis -REDIS_HOST=10.0.0.112 +REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD=TuPasswordSegura123 REDIS_DB=0 @@ -35,6 +35,16 @@ ENVIRONMENT=development # Elastic #ELASTIC_URL=http://host.docker.internal:9200 ELASTIC_URL=http://10.0.0.124:9200 -ELASTIC_ENABLED=true +ELASTIC_ENABLED=false -ENCRYPTION_KEY=12345678901234567890123456789012 \ No newline at end of file +ENCRYPTION_KEY=12345678901234567890123456789012 + + + +AUTH_ENDPOINT= http://localhost:8085/auth/headers +AUTH_AUTORIZATION= 'Basic d29ya2VyLXJlbmRpY2lvbjpwcnVlYmE=' +AUTH_METHOD=POST + +TM_HEADER_ORIGIN=https://azure-function.timemanagerweb.com +TM_HEADER_TENANT_NAME=pruebas-dos +TM_HEADER_USER_AGENT='Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36' diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/internal/config/config.go b/internal/config/config.go index b9a4cb1..f650e95 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -44,6 +44,17 @@ type Config struct { TLSSkipVerify bool `mapstructure:"TLS_SKIP_VERIFY"` LogRequests bool `mapstructure:"LOG_REQUESTS"` EnableDebug bool `mapstructure:"ENABLE_DEBUG"` + + //AUTH + AUTH_ENDPOINT string `mapstructure:"AUTH_ENDPOINT"` + AUTH_AUTORIZATION string `mapstructure:"AUTH_AUTORIZATION"` + AUTH_METHOD string `mapstructure:"AUTH_METHOD"` + + //TM + + TM_HEADER_ORIGIN string `mapstructure:"TM_HEADER_ORIGIN"` + TM_HEADER_TENANT_NAME string `mapstructure:"TM_HEADER_TENANT_NAME"` + TM_HEADER_USER_AGENT string `mapstructure:"TM_HEADER_USER_AGENT"` } var GlobalConfig *Config diff --git a/internal/domain/dto/auth.go b/internal/domain/dto/auth.go new file mode 100644 index 0000000..16e7c10 --- /dev/null +++ b/internal/domain/dto/auth.go @@ -0,0 +1,22 @@ +package dto + +import ( + "time" +) + +type Data struct { + SourceID int `json:"source_id"` + SourceName string `json:"source_name"` + ExpiresAt time.Time `json:"expires_at"` + Headers map[string]string `json:"headers"` + URL string `json:"url"` +} + +type Response struct { + Status int `json:"status"` + Success bool `json:"success"` + Message string `json:"message"` + Data Data `json:"data"` + TraceID string `json:"trace_id"` + Timestamp time.Time `json:"timestamp"` +} diff --git a/internal/domain/dto/session.go b/internal/domain/dto/session.go index ed082eb..ca0d18d 100644 --- a/internal/domain/dto/session.go +++ b/internal/domain/dto/session.go @@ -3,7 +3,8 @@ package dto import "time" type SessionData struct { - SessionId string `json:"session_id"` - ExpiresAt time.Time `json:"expires_at"` - EndPoint string `json:"end_point"` + //SessionId string `json:"session_id"` + Headers map[string]string `json:"headers"` + ExpiresAt time.Time `json:"expires_at"` + EndPoint string `json:"end_point"` } diff --git a/internal/domain/dto/trace.go b/internal/domain/dto/trace.go new file mode 100644 index 0000000..e2baf4a --- /dev/null +++ b/internal/domain/dto/trace.go @@ -0,0 +1,16 @@ +package dto + +import "time" + +type RequestTrace struct { + Method string + URL string + Headers map[string]string + RequestBody string + Response string + StatusCode int + Duration time.Duration + Retries int + StartedAt time.Time + EndedAt time.Time +} diff --git a/internal/http/external_api/sap_cliente.go b/internal/http/external_api/sap_cliente.go new file mode 100644 index 0000000..d6b90f3 --- /dev/null +++ b/internal/http/external_api/sap_cliente.go @@ -0,0 +1,100 @@ +package external_api + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "errors" + "github.com/tuusuario/go-sync-service/internal/domain/dto" + "io" + "net/http" + "time" +) + +// Estructura para guardar trazabilidad + +// Client genérico con reintentos +type GenericClient struct { + Client *http.Client + MaxRetries int + RetryDelay time.Duration + EnableTrace bool // true = guarda trazabilidad +} + +// Constructor +func NewGenericClient(maxRetries int, retryDelay time.Duration) *GenericClient { + return &GenericClient{ + Client: &http.Client{Timeout: 30 * time.Second, Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }}, + MaxRetries: maxRetries, + RetryDelay: retryDelay, + } +} + +// Método genérico +func (gc *GenericClient) DoRequest(method, url string, headers map[string]string, body []byte) (map[string]interface{}, *dto.RequestTrace, error) { + trace := &dto.RequestTrace{ + Method: method, + URL: url, + Headers: headers, + RequestBody: string(body), + StartedAt: time.Now(), + } + + var lastErr error + var resp *http.Response + + for attempt := 0; attempt <= gc.MaxRetries; attempt++ { + trace.Retries = attempt + + req, err := http.NewRequest(method, url, bytes.NewReader(body)) + if err != nil { + lastErr = err + break + } + + // Headers + for k, v := range headers { + req.Header.Set(k, v) + } + + start := time.Now() + resp, err = gc.Client.Do(req) + trace.Duration = time.Since(start) + + if err == nil && resp.StatusCode < 500 { + break // éxito o error de cliente, no reintentar + } + + lastErr = err + time.Sleep(gc.RetryDelay * (1 << attempt)) // backoff exponencial + } + + if lastErr != nil { + trace.EndedAt = time.Now() + return nil, trace, lastErr + } + defer resp.Body.Close() + + trace.StatusCode = resp.StatusCode + bodyResp, _ := io.ReadAll(resp.Body) + trace.Response = string(bodyResp) + trace.EndedAt = time.Now() + + if resp.StatusCode >= 400 { + return nil, trace, errors.New("HTTP error: " + resp.Status) + } + + // Si el código de estado es 204 (No Content), no hay cuerpo de respuesta + if resp.StatusCode == http.StatusNoContent { + return nil, trace, nil + } + + var result map[string]interface{} + if err := json.Unmarshal(bodyResp, &result); err != nil { + return nil, trace, err + } + + return result, trace, nil +} diff --git a/internal/http/session.go b/internal/http/session.go index 6c1893b..b5336a0 100644 --- a/internal/http/session.go +++ b/internal/http/session.go @@ -3,6 +3,8 @@ package http import ( "encoding/json" "fmt" // NEW + "github.com/tuusuario/go-sync-service/internal/http/external_api" + "log" "sync" "time" @@ -10,7 +12,6 @@ import ( "github.com/tuusuario/go-sync-service/internal/domain/dto" "github.com/tuusuario/go-sync-service/internal/domain/model" "github.com/tuusuario/go-sync-service/internal/domain/ports" - "github.com/tuusuario/go-sync-service/internal/security" ) var ( @@ -21,90 +22,108 @@ var ( type SAPSessionProvider struct{} func GetSession(cfg ports.RedisConfigProvider, job dto.CronJob, auth dto.ServiceConfig, dbcore ports.Database, logPrefix string) (*dto.SessionData, error) { - mutex.Lock() - defer mutex.Unlock() - redisKey = "session:" + job.UnidadNegocio.CompanyName + ":" + job.UnidadNegocio.CompanyDB - - if sess, err := loadSessionFromRedis(cfg, logPrefix); err == nil && time.Now().Before(sess.ExpiresAt) { - config.Log.Printf("%v 🔑 Sesión obtenida de Redis %v", logPrefix, redisKey) - return sess, nil - } - - parametros := map[string]interface{}{ - "company_name": job.UnidadNegocio.CompanyName, - "company_db": job.UnidadNegocio.CompanyDB, - } - - credencial, err := dbcore.GetCredencialesFromTemplate(config.GlobalConfig.WhereUnitsBusiness, parametros) + //CLIENT HTTP + authenticacion, _, err := Authenticacion(job.UnidadNegocio.CompanyDB) if err != nil { - config.Log.Printf("%v ❌ Error al obtener credenciales: %v", logPrefix, err) + log.Printf("Error en autenticacion: %v", err) return nil, err } - // ============================== - // DESCIFRAR PASSWORD (AES-GCM) - // ============================== - key, err := security.LoadEncryptionKey() - if err != nil { - return nil, fmt.Errorf("%s %v", logPrefix, err) - } - // Suponemos que credencial.Password viene como base64(nonce||cipher) generado por EncryptAESGCM - plainPass, err := security.DecryptAESGCM(credencial.Password, key) - if err != nil { - config.Log.Printf("%v ❌ Error al descifrar password: %v", logPrefix, err) - return nil, err - } - credencial.Password = plainPass + return &dto.SessionData{ + Headers: authenticacion.Data.Headers, + ExpiresAt: authenticacion.Data.ExpiresAt, + EndPoint: authenticacion.Data.URL, + }, nil - // No loguees secretos. Si necesitas log, hazlo sin password: - config.Log.Debugf("%v Obteniendo credenciales para CompanyDB=%s UserName=%s (password oculto)", - logPrefix, credencial.CompanyDB, credencial.UserName) - - config.Log.Printf("%v 🔑 Realizando login...", logPrefix) - - mySession := &dto.SessionData{EndPoint: credencial.EndPoint} - - if auth.Rest == nil { - auth.Rest = &dto.RestOptions{} - } - - // 3. Preparar el body del login (usa cred.Password en claro ya descifrado) - auth = prepareAuthBody(auth, credencial, logPrefix) - - config.Log.Debugf(" %v Url: %v + %v", logPrefix, credencial.EndPoint, auth.Path) - resp, err := SendRequest(credencial.EndPoint, auth) - if err != nil || resp.IsError() { - config.Log.Printf("%v ❌ Error al autenticar: %v", logPrefix, err) - return nil, err - } - - if auth.GQL { - var dataGraphql struct { - Token string `json:"token"` - RefreshToken string `json:"refresh_token"` + /* + mutex.Lock() + defer mutex.Unlock() + redisKey = "session:" + job.UnidadNegocio.CompanyName + ":" + job.UnidadNegocio.CompanyDB + //REDIS + if sess, err := loadSessionFromRedis(cfg, logPrefix); err == nil && time.Now().Before(sess.ExpiresAt) { + config.Log.Printf("%v 🔑 Sesión obtenida de Redis %v", logPrefix, redisKey) + return sess, nil } - if err := json.Unmarshal(resp.Body(), &dataGraphql); err != nil { - config.Log.Printf("%v ❌ Error al parsear sesión graphql: %v", logPrefix, err) - return nil, err - } - mySession.SessionId = dataGraphql.Token - mySession.ExpiresAt = time.Now().Add(10 * time.Minute) - } else { - var dataRest struct { - SessionId string `json:"SessionId"` - SessionTimeout int `json:"SessionTimeout"` - } - if err := json.Unmarshal(resp.Body(), &dataRest); err != nil { - config.Log.Printf("%v ❌ Error al parsear sesión rest: %v", logPrefix, err) - return nil, err - } - mySession.SessionId = dataRest.SessionId - mySession.ExpiresAt = time.Now().Add(time.Duration(dataRest.SessionTimeout) * time.Minute) - } - config.Log.Printf("%v ✅ Sesión obtenida", logPrefix) - saveSessionToRedis(mySession, cfg, logPrefix) - return mySession, nil + + + + parametros := map[string]interface{}{ + "company_name": job.UnidadNegocio.CompanyName, + "company_db": job.UnidadNegocio.CompanyDB, + } + + credencial, err := dbcore.GetCredencialesFromTemplate(config.GlobalConfig.WhereUnitsBusiness, parametros) + if err != nil { + config.Log.Printf("%v ❌ Error al obtener credenciales: %v", logPrefix, err) + return nil, err + } + + // ============================== + // DESCIFRAR PASSWORD (AES-GCM) + // ============================== + key, err := security.LoadEncryptionKey() + if err != nil { + return nil, fmt.Errorf("%s %v", logPrefix, err) + } + // Suponemos que credencial.Password viene como base64(nonce||cipher) generado por EncryptAESGCM + plainPass, err := security.DecryptAESGCM(credencial.Password, key) + if err != nil { + config.Log.Printf("%v ❌ Error al descifrar password: %v", logPrefix, err) + return nil, err + } + credencial.Password = plainPass + + // No loguees secretos. Si necesitas log, hazlo sin password: + config.Log.Debugf("%v Obteniendo credenciales para CompanyDB=%s UserName=%s (password oculto)", + logPrefix, credencial.CompanyDB, credencial.UserName) + + config.Log.Printf("%v 🔑 Realizando login...", logPrefix) + + mySession := &dto.SessionData{EndPoint: credencial.EndPoint} + + if auth.Rest == nil { + auth.Rest = &dto.RestOptions{} + } + + // 3. Preparar el body del login (usa cred.Password en claro ya descifrado) + auth = prepareAuthBody(auth, credencial, logPrefix) + + config.Log.Debugf(" %v Url: %v + %v", logPrefix, credencial.EndPoint, auth.Path) + resp, err := SendRequest(credencial.EndPoint, auth) + if err != nil || resp.IsError() { + config.Log.Printf("%v ❌ Error al autenticar: %v", logPrefix, err) + return nil, err + } + + if auth.GQL { + var dataGraphql struct { + Token string `json:"token"` + RefreshToken string `json:"refresh_token"` + } + if err := json.Unmarshal(resp.Body(), &dataGraphql); err != nil { + config.Log.Printf("%v ❌ Error al parsear sesión graphql: %v", logPrefix, err) + return nil, err + } + mySession.SessionId = dataGraphql.Token + mySession.ExpiresAt = time.Now().Add(10 * time.Minute) + } else { + var dataRest struct { + SessionId string `json:"SessionId"` + SessionTimeout int `json:"SessionTimeout"` + } + if err := json.Unmarshal(resp.Body(), &dataRest); err != nil { + config.Log.Printf("%v ❌ Error al parsear sesión rest: %v", logPrefix, err) + return nil, err + } + mySession.SessionId = dataRest.SessionId + mySession.ExpiresAt = time.Now().Add(time.Duration(dataRest.SessionTimeout) * time.Minute) + } + + config.Log.Printf("%v ✅ Sesión obtenida", logPrefix) + saveSessionToRedis(mySession, cfg, logPrefix) + return mySession, nil + */ } func loadSessionFromRedis(cfg ports.RedisConfigProvider, logPrefix string) (*dto.SessionData, error) { @@ -151,3 +170,53 @@ func prepareAuthBody(auth dto.ServiceConfig, cred *model.CredencialesSAP, logPre } return auth } + +func Authenticacion(company string) (*dto.Response, *dto.RequestTrace, error) { + url := config.GlobalConfig.AUTH_ENDPOINT + + headers := map[string]string{ + "Content-Type": "application/json", + "Authorization": config.GlobalConfig.AUTH_AUTORIZATION, + } + + // Crea el cuerpo de la solicitud con el id recibido + body := []byte(fmt.Sprintf(`{"name": "%s"}`, company)) + + client := external_api.NewGenericClient(3, 3*time.Second) + // Hacer la solicitud usando el cliente genérico + response, trace, err := client.DoRequest(config.GlobalConfig.AUTH_METHOD, url, headers, body) + + // Manejo de errores + if err != nil { + log.Println("Error en la solicitud: ", err) + + return nil, nil, err + } + + // Deserializar la respuesta en el modelo de dto.Response + var responseModel dto.Response + err = mapToModel(response, &responseModel) + if err != nil { + log.Println("Error al deserializar la respuesta: ", err) + return nil, nil, err + } + log.Printf("TRACE: %+v", trace) + log.Printf("RESPUESTA MAP: %+v", response) + return &responseModel, trace, nil +} + +// mapToModel convierte un mapa genérico en el modelo ResponseModel +func mapToModel(data map[string]interface{}, model *dto.Response) error { + // Utiliza json.Marshal y json.Unmarshal para convertir el mapa en el modelo + jsonData, err := json.Marshal(data) + if err != nil { + return err + } + + err = json.Unmarshal(jsonData, model) + if err != nil { + return err + } + + return nil +} diff --git a/internal/sync/fetcher.go b/internal/sync/fetcher.go index 8109c95..ce7d5c7 100644 --- a/internal/sync/fetcher.go +++ b/internal/sync/fetcher.go @@ -47,16 +47,17 @@ func SyncData(redis ports.RedisConfigProvider, database *gorm.DB, job dto.CronJo hasError = true continue } - if session == nil || session.SessionId == "" { + if session == nil || session.Headers == nil { config.Log.Println(logPrefix + " ❌ Sesión inválida o vacía") hasError = true continue } + jobIndividual.Service.Headers = session.Headers if jobIndividual.Service.GQL { - jobIndividual.Service.Headers["Authorization"] = "Bearer " + session.SessionId - } else { - jobIndividual.Service.Headers["Cookie"] = "B1SESSION=" + session.SessionId + jobIndividual.Service.Headers["origin"] = config.GlobalConfig.TM_HEADER_ORIGIN + jobIndividual.Service.Headers["tenant-name"] = config.GlobalConfig.TM_HEADER_TENANT_NAME + jobIndividual.Service.Headers["User-Agent"] = config.GlobalConfig.TM_HEADER_USER_AGENT } response, err := FetchAllPaginatedManual[map[string]interface{}](session.EndPoint, jobIndividual.Service, logPrefix)